回顾 -> 问题引出
- DeepRearchSystem 0x00:初识
- DeepRearchSystem 0x01:Agent 基础
- DeepRearchSystem 0x02:Graph 构建
- DeepRearchSystem 0x03:HITL
- DeepResearchSystem 0x04:MAS 进阶
- DeepResearchSystem 0x06:LLM as Judge
- DeepResearchSystem 0x07:查询缓存
上一章] 实现了 DeepRearchSystem 的搜索缓存。解决了短时间内同样主题重复请求的问题。现在又遇到了新的挑战:
-
search_cache本身解决的是短时间内的重复请求问题,我们设计的TTL也是小时级别。那么如果是第二天出现了同样的query该怎么办呢?是否只能按正常流程重新跑一遍。 -
search_cache的 key设计是:(prompt, count),本质是query-prompt的精确匹配。比如 result1 = query("AI芯片销量"), 下次 query("GPU出货量"):因为 prompt 不能匹配,就必须重新跑一遍。但是呢,我们都知道 "AI芯片销量" 和 "GPU出货量"是息息相关的,上次的检索结果对这次查询完全有可复用的资料。
3.search_cache 解决的是短期内并发请求的重复调用问题。一旦跨会话/跨项目,DeepRearchSystem 每次只能重来。Agent 是有能力但只会蛮干,就像 "鱼的记忆只有7s",如果能让 DeepRearchSystem 像人一样有记忆就好了。
怎么办?(思考方向)
其实,到这里要做的事情就有点清晰了:要让 Agent 具备长期记忆 ,当 query 来临时先查自己的记忆,记忆里没有再查短期缓存,短期缓存没有再走搜索流程重新沉淀。沉淀后的知识再反哺记忆。概括起来需要具备:
-
具备长期记忆,可以优先从记忆中检索
-
不局限于精确匹配,而是支持语义匹配。
咦,听起来是不是像 RAG 知识库。确实有点像,但还是有点差别的。至于哪些地方有差别,我们先去设计和实现之后再回来看待这个问题。
干他 (架构设计)

事实提取器
extractor: 事实提取器。从搜索摘要中精准剥离出离散、可验证的事实,并自动打标(置信度、类别等)
- 置信度:对事实进行验证,记忆只存储经验证的有置信度打分的数据
- 类别:用于
lifecycle对数据的 TTL 管理,不同类型数据的过期时间不同
事实记忆器
fact_store: 事实存储器。基于 Milvus 的向量数据库存储。
vector选型:支持高效的语义检索
TTL 架构
lifecycle: TTL 规则器。决定哪些类型的数据有效期短,哪些类型有效期长。用来保证知识新鲜度。
年龄阈值
FRESHNESS_MAX_AGE: 年龄阈值(天)
high: 7。7 天有效期medium: 30low: 180
生命周期
KBLifecycleMode: KB模式
- off/关闭:不进行过滤、不衰减、不添加年龄标记。
- inform/通知:添加年龄标记并更改措辞 ,但不进行过滤或置信度衰减
- 开关打开,为搜索数据增加
age_days时效时效时效 - 根据
age_days参数修改 Prompt:- "知识库中已有的相关事实(请勿重复搜索这些内容)"
- "标记较早的事实可能已过时,请优先搜索获取最新信息"
- 开关打开,为搜索数据增加
- freshness/新鲜度:Plan LLM判定 → 统一的年龄过滤和置信度衰减。
- 置信度衰减 :随着时间推移记忆数据的置信度理应衰减
- 曲线衰减:
decay = e^(-lambda * age) - 类别敏感度:对应
market_data到historical分布不同
- 曲线衰减:
- 置信度衰减 :随着时间推移记忆数据的置信度理应衰减
- lifecycle/生命周期:事实类别 → 按类别进行生命周期时间 (TTL) 过滤和衰减。
market_data7, 市场数据------7天过期product_info: 30, 产品信息------30天strategy: 90, 战略方向------90天technology: 180, 技术定义一般有效期更长------180天historical: None, 历史事件------永不过期
至此,整个 KB 框架就构思好了,接下来就是搬砖🧱实现了。
Coding
extractor
核心代码
注意最终返回的结构:
fact:事实数据source_url:数据来源 (必须是真实 URL, 而不是前面流程构造的短url)confidence:置信度,由 LLM 判断的 事实可信度category:事实类型,是技术类还是产品类
Python
class FactExtractor:
"""
从搜索结果摘要中提取结构化事实。
"""
def __init__(self, model_id="deepseek-v4-pro"):
self.model_id = model_id
def extract(self, summary: str, research_topic: str = "") -> list[dict]:
"""
从单个搜索结果摘要中提取事实。
Args:
summary: 网页搜索结果文本(已由 LLM 进行摘要)
research_topic: 用于提供上下文的整体研究主题
Returns:
List of {fact, source_url, confidence} dicts.
"""
if not summary or len(summary) < 50:
logger.debug("[KB-extractor] 摘要过短,省略提取")
return []
agent = Agent(model_id=self.model_id) if self.model_id else Agent()
agent.set_step_prompt(EXTRACTION_INSTRUCTIONS)
raw = agent.step(
research_topic=research_topic or "(extracting facts)",
summary=summary[:8000], # truncate for safety
)
try:
json_str = JsonUtils.extract_pattern(raw, pattern="json")
facts = json.loads(json_str)
if isinstance(facts, list):
facts = self._validate(facts)
logger.info(
f"[KB-extractor] extracted {len(facts)} facts "
f"from summary ({len(summary)} chars)"
)
return facts
except json.JSONDecodeError as exc:
logger.warning(
f"[KB-extractor] JSON parse failed --- LLM returned malformed JSON: {exc}"
)
except (ValueError, TypeError) as exc:
logger.warning(
f"[KB-extractor] data validation failed ({type(exc).__name__}): {exc}"
)
except Exception as exc:
logger.warning(
f"[KB-extractor] failed to parse facts ({type(exc).__name__}): {exc}"
)
return []
@staticmethod
def _validate(facts: list) -> list[dict]:
"""
过滤和规整提取的facts.
"""
valid = []
for f in facts:
if not isinstance(f, dict):
continue
fact_text = f.get("fact", "").strip()
if not fact_text or len(fact_text) < 10:
continue
valid.append({
"fact": fact_text,
"source_url": f.get("source_url", ""),
"confidence": min(1.0, max(0.0, float(f.get("confidence", 0.7)))),
"fact_category": f.get("fact_category", "strategy"),
})
if len(valid) >= 10:
break
return valid
Prompt
那么 LLM 是怎么提取出以上事实的?就依赖于我们写的 Prompt。
Python
EXTRACTION_INSTRUCTIONS = """# 任务说明
你是一个知识提取专家。从给定的搜索摘要中提取出离散的、可验证的事实陈述。
# Instruction
1. 每条 fact 必须是一个独立的、可验证的陈述(一句话),避免复合句
2. 每条 fact 标注来源 URL(从摘要中的 [来源名](url) 格式提取)
3. 每条 fact 标注 confidence(0.0-1.0):
- 0.9-1.0:摘要明确引用具体数据/事件
- 0.7-0.8:摘要中有较明确的表述
- 0.5-0.6:摘要中隐含但未直接说明
4. 标注每条 fact 的 category(事实时效性类别):
- market_data: 市场份额、价格、排名、增长率、营收数据
- product_info: 产品功能、规格、版本、发布信息
- strategy: 公司战略、投资方向、合作、收购
- technology: 技术原理、架构定义、标准、协议
- historical: 历史事件、里程碑、已发生的事实
5. 不要提取过于泛泛的陈述(如"这是一个重要市场")
6. 每条 fact 控制在 80 字以内
7. 最多提取 10 条 fact
# 输出格式
```json
[
{
"fact": "2025年Q1全球AI编程助手市场规模达到15亿美元",
"source_url": "https://xxxx.com/xxxx",
"confidence": 0.9,
"category": "market_data"
},
{
"fact": "GitHub Copilot 占据AI编程助手市场约35%的份额",
"source_url": "https://xxxx.com/xxxx",
"confidence": 0.8,
"category": "market_data"
}
]
```
# 研究主题(用于上下文理解)
{research_topic}
# 搜索摘要
{summary}
# 输出"""
fact_store
基于 Milvus 的研究知识库事实存储库。
Milvus
和之前我们使用 ChromaDB 一样,Milvus 使用基本流程相同。
Python
@property
def client(self) -> MilvusClient:
if self._client is None:
self._client = MilvusClient(uri=self.uri)
return self._client
def _ensure_collection(self) -> None:
"""创建集合(如果不存在)。"""
# 检查集合是否已存在
try:
if self.client.has_collection(self.collection):
logger.info(f"[KB] collection '{self.collection}' already exists")
return
except Exception as e:
raise KBConnectionError(
f"Milvus 连接失败(检查集合是否存在时): {e}"
) from e
# 创建集合
try:
self.client.create_collection(
collection_name=self.collection,
dimension=self.embedding_dim,
metric_type="COSINE",
auto_id=True,
enable_dynamic_field=True,
)
except Exception as e:
msg = str(e).lower()
if "dimension" in msg or "param" in msg or "schema" in msg:
raise KBConfigError(
f"Milvus 集合创建配置错误: {e}"
) from e
raise KBConnectionError(
f"Milvus 集合创建失败: {e}"
) from e
# 创建 IVF_FLAT 索引以提高搜索效率。
# Note: 某些 Milvus 版本会自动创建默认索引。
try:
index_params = IndexParams()
index_params.add_index(
field_name="vector",
index_type="IVF_FLAT",
metric_type="COSINE",
params={"nlist": 128},
)
self.client.create_index(
collection_name=self.collection,
index_params=index_params,
)
self.client.load_collection(self.collection)
except Exception as exc:
# Milvus may raise if index already exists; this is harmless
error_msg = str(exc).lower()
if "already exist" in error_msg or "duplicate" in error_msg:
logger.debug(f"[KB] index already exists, skipping creation")
else:
logger.warning(
f"[KB] index creation skipped ({type(exc).__name__}): {exc}"
)
logger.info(
f"[KB] created collection '{self.collection}' "
f"(dim={self.embedding_dim}, metric=COSINE)"
)
Embedding
这块就很熟悉了,和之前 RAG 项目中的向量化流程一毛一样。这里要注意几点:
-
base_url拼接要注意检查给定厂商是否带了v1 -
做好异常捕获和处理
- 实际dim与配置dim的差异:即使输出警告排查
- 重试策略 :按错误码分情况处理,429/5xx 可重试,重试采取指数退避算法
- Json 解析的错误处理
Python
def _embed(self, texts: list[str]) -> list[list[float]]:
"""
从兼容 OpenAI 的端点获取嵌入向量。
最多重试 3 次,失败后采用退避策略。
配置优先级:
- EMBEDDING_BASE_URL → LLM_BASE_URL(fallback)
- EMBEDDING_API_KEY → APP_TOKEN(fallback)
"""
base_url = os.getenv("EMBEDDING_BASE_URL") or os.getenv("LLM_BASE_URL")
api_key = os.getenv("EMBEDDING_API_KEY") or os.getenv("APP_TOKEN")
if not base_url:
raise KBConfigError(
"Embedding URL 未配置:请设置 EMBEDDING_BASE_URL 或 LLM_BASE_URL 环境变量"
)
if not api_key:
raise KBConfigError(
"Embedding API Key 未配置:请设置 EMBEDDING_API_KEY 或 APP_TOKEN 环境变量"
)
# 大多数兼容 OpenAI 的端点都支持 /v1/embeddings
# 如果base URL 已经以 /v1 结尾,需要相应调整。
if base_url.endswith("/v1"):
url = f"{base_url}/embeddings"
else:
url = f"{base_url}/v1/embeddings"
headers = {
"Authorization": f"Bearer {api_key}",
"Content-Type": "application/json",
}
payload = {
"model": self.embedding_model,
"input": texts,
}
last_exc: Exception | None = None
for attempt in range(3):
try:
logger.debug(f"[KB] embedding {len(texts)} texts via {url} (model={self.embedding_model}, attempt={attempt + 1})")
resp = requests.post(url, json=payload, headers=headers, timeout=30)
resp.raise_for_status()
data = resp.json()
embeddings = [item["embedding"] for item in data["data"]]
# 按索引排序以保持顺序
embeddings.sort(key=lambda x: x.get("index", 0) if isinstance(x, dict) else 0)
result = [item["embedding"] if isinstance(item, dict) else item for item in embeddings]
# 自动检测实际dim与配置dim的差异
if result and len(result[0]) != self.embedding_dim:
logger.warning(
f"[KB] embedding dim mismatch: configured={self.embedding_dim}, "
f"actual={len(result[0])}. Update EMBEDDING_DIM env var."
)
return result
except requests.HTTPError as e:
last_exc = e
status = e.response.status_code if e.response is not None else 0
# 不可恢复:认证/权限/参数错误 --- 不重试
if status in (401, 403):
logger.error(
f"[KB] embedding auth error HTTP {status}, not retrying: {e}"
)
raise KBEmbeddingFatalError(
f"Embedding API 认证/权限失败 (HTTP {status})"
) from e
if status == 400:
logger.error(
f"[KB] embedding bad request HTTP 400, not retrying: {e}"
)
raise KBEmbeddingFatalError(
f"Embedding API 参数错误 (HTTP 400)"
) from e
# 可恢复:429 / 5xx --- 重试
if status == 429 and attempt < 2:
wait = 3 * (attempt + 1)
logger.warning(f"[KB] embedding 429 rate-limited, retrying in {wait}s (attempt {attempt + 1}/3)")
time.sleep(wait)
elif status >= 500 and attempt < 2:
wait = 2 * (attempt + 1)
logger.warning(f"[KB] embedding server error {status}, retrying in {wait}s (attempt {attempt + 1}/3)")
time.sleep(wait)
else:
logger.error(f"[KB] embedding HTTP {status}, no more retries: {e}")
raise KBEmbeddingError(
f"Embedding API HTTP {status} 重试耗尽"
) from e
except (requests.ConnectionError, requests.Timeout) as e:
last_exc = e
if attempt < 2:
wait = 1.5 * (attempt + 1)
logger.warning(f"[KB] embedding network error, retrying in {wait:.1f}s (attempt {attempt + 1}/3): {e}")
time.sleep(wait)
else:
logger.error(f"[KB] embedding network error, no more retries: {e}")
raise KBEmbeddingError(
f"Embedding 网络错误重试耗尽: {e}"
) from e
except (ValueError, KeyError, TypeError) as e:
# JSON 解析 / 数据结构错误 --- 永久错误,不重试
logger.error(
f"[KB] embedding response parse error ({type(e).__name__}), "
f"not retrying: {e}"
)
raise KBEmbeddingFatalError(
f"Embedding 响应解析失败: {e}"
) from e
except Exception as e:
# 未知异常 --- 保守重试
last_exc = e
if attempt < 2:
wait = 1.5 * (attempt + 1)
logger.warning(
f"[KB] embedding unexpected error ({type(e).__name__}), "
f"retrying in {wait:.1f}s (attempt {attempt + 1}/3): {e}"
)
time.sleep(wait)
else:
logger.error(
f"[KB] embedding unexpected error ({type(e).__name__}), "
f"no more retries: {e}"
)
raise KBEmbeddingError(
f"Embedding 未知错误重试耗尽 ({type(e).__name__}): {e}"
) from e
raise KBEmbeddingError(
f"[KB] embedding failed after 3 attempts: {last_exc}"
) from last_exc
async def _aembed(self, texts: list[str]) -> list[list[float]]:
"""
异步 embedding------用 asyncio.to_thread 包裹同步方法,不阻塞事件循环.
"""
return await asyncio.to_thread(self._embed, texts)
主类
Python
class FactStore:
"""
Milvus-backed storage and retrieval of research facts.
"""
def __init__(
self,
uri: str | None = None,
collection: str = COLLECTION_NAME,
embedding_dim: int | None = None,
embedding_model: str | None = None,
):
self.uri = uri or os.getenv("MILVUS_URI", "http://localhost:19530")
self.collection = collection
self.embedding_dim = embedding_dim or int(
os.getenv("EMBEDDING_DIM", str(DEFAULT_EMBEDDING_DIM))
)
self.embedding_model = embedding_model or os.getenv(
"EMBEDDING_MODEL", DEFAULT_EMBEDDING_MODEL
)
self._client: MilvusClient | None = None
self._ensure_collection()
set
好像没什么可说的,就是 数据的分块向量化存储那一块。
Python
def add_facts(self, facts: list[dict]) -> int:
"""
将fact嵌入并插入到 Milvus 中。返回已插入的事实数量。
每个fact字典必须包含:
- fact (str): 事实陈述
- source_url (str): 事实来源
Optional:
- research_topic (str): 触发此搜索的研究主题
- confidence (float): 0.0-1.0
"""
if not facts:
return 0
texts = [f["fact"] for f in facts]
embeddings = self._embed(texts)
data = []
now = int(time.time())
for i, fact in enumerate(facts):
data.append({
"vector": embeddings[i],
"fact_text": fact["fact"],
"source_url": fact.get("source_url", ""),
"research_topic": fact.get("research_topic", ""),
"confidence": float(fact.get("confidence", 1.0)),
"category": fact.get("category", "strategy"),
"created_at": now,
})
result = self.client.insert(collection_name=self.collection, data=data)
inserted = result.get("insert_count", len(data))
logger.info(
f"[KB] stored {inserted} facts → Milvus/{self.collection} "
f"(topics: {set(f.get('research_topic', '') for f in facts)})"
)
return inserted
get
查询的时候要注意根据 lifecycle 策略做好年龄时效性和置信度衰减的过滤。同时置信度衰减要按不同类型的数据设置不同的衰减系数。
ini
def query(
self,
topic: str,
top_k: int = 10,
min_confidence: float = 0.0,
max_age_days: int | None = None,
decay: bool = False,
lifecycle_mode: bool = False,
) -> list[dict]:
"""
对与研究主题相关的fact进行语义搜索。
Args:
topic: 用于语义搜索的研究主题文本。
top_k: 期望返回的事实数量。若启用 reranker,Milvus 会召回
max(top_k * 3, 20) 条候选,再由 reranker 精排到 top_k。
min_confidence: 过滤前的最小置信度(0.0-1.0)。
max_age_days: 排除超过指定天数的事实。None 表示无限制。
在 lifecycle_mode 模式下,此参数将被忽略,而是使用按类别划分的 TTL(生存时间)。
decay: 如果为 True,则应用基于时间的置信度衰减。
lifecycle_mode: 如果为 True,则使用按类别划分的 TTL(CATEGORY_TTL),
而不是单一的 max_age_days 阈值。
Returns list of {fact, source_url, confidence, research_topic, relevance,
created_at, age_days, category}.
"""
embedding = self._embed([topic])
milvus_limit = max(top_k * 3, 20)
results = self.client.search(
collection_name=self.collection,
data=[embedding[0]],
limit=milvus_limit,
output_fields=[
"fact_text", "source_url", "research_topic",
"confidence", "created_at", "category",
],
)
if not results or not results[0]:
logger.info(f"[KB] query '{topic[:60]}...' → 0 results")
return []
# ── 收集 Milvus 原始候选 ──────────────────────────────────
now = time.time()
raw_candidates = []
for hit in results[0]:
entity = hit.get("entity", {})
distance_score = 1.0 - hit.get("distance", 0)
raw_candidates.append({
"entity": entity,
"distance_score": distance_score,
})
candidates = raw_candidates
# ── 置信度 & 时效过滤 ─────────────────────────────────────
hits = []
for cand in candidates:
entity = cand["entity"]
raw_confidence = entity.get("confidence", 1.0)
if raw_confidence < min_confidence:
continue
created = entity.get("created_at", 0)
age_days = (now - created) / 86400 if created else 36500
# ── 年龄时效性过滤 ──
if lifecycle_mode:
category = entity.get("category", "strategy")
category_max_age = CATEGORY_TTL.get(category)
if category_max_age is not None and age_days > category_max_age:
continue
elif max_age_days and age_days > max_age_days:
continue
# ── 置信度衰减 ──
confidence = raw_confidence
if decay:
category = entity.get("category", "strategy")
ttl = CATEGORY_TTL.get(category, 180)
# 1. 如果超过TTL,直接归零(除非是historical)
if ttl is not None and age_days > ttl:
confidence = 0.0
else:
# 2. 指数衰减公式: decay = e^(-lambda * age)
# lambda 是衰减系数,不同类别不同
decay_lambda = {
"market_data": 0.1, # 衰减极快
"product_info": 0.05, # 衰减快
"strategy": 0.02, # 衰减中等
"technology": 0.005, # 衰减慢
"historical": 0.0 # 不衰减
}.get(category, 0.01)
decay_factor = math.exp(-decay_lambda * age_days)
# 设置一个硬下限,防止完全消失(除非超TTL)
decay_factor = max(0.1, decay_factor) if category != "historical" else 1.0
confidence = raw_confidence * confidence * decay_factor
# relevance 优先用 rerank_score,否则用 Milvus 距离得分
orig_idx = cand.get("_orig_idx")
hits.append({
"fact": entity.get("fact_text", ""),
"source_url": entity.get("source_url", ""),
"research_topic": entity.get("research_topic", ""),
"confidence": round(confidence, 2),
"created_at": created,
"age_days": round(age_days, 1),
"category": entity.get("category", "strategy"),
})
logger.info(
f"[KB] query '{topic[:60]}...' → {len(hits)} hits "
f"(top score={hits[0]['relevance']:.3f})"
if hits else f"[KB] query '{topic[:60]}...' → 0 hits"
)
return hits
lifecycle
基本定义
Python
class KBLifecycleMode(str, Enum):
OFF = "off"
INFORM = "inform"
FRESHNESS = "freshness"
LIFECYCLE = "lifecycle"
# ── 统一年龄阈值 (freshness mode) ───────────────────────────
FRESHNESS_MAX_AGE: dict[str, int] = {
"high": 7,
"medium": 30,
"low": 180,
}
# ── 按类别划分的 TTL (lifecycle mode) ─────────────────────────────────
CATEGORY_TTL: dict[str, int | None] = {
"market_data": 7, # 市场数据------7天过期
"product_info": 30, # 产品信息------30天
"strategy": 90, # 战略方向------90天
"technology": 180, # 技术定义------180天
"historical": None, # 历史事件------永不过期
}
模式获取
Python
def get_mode() -> KBLifecycleMode:
"""
从环境变量中读取当前生命周期模式。
如果值无效或未设置,则回退到 FRESHNESS。
"""
raw = os.getenv("KB_LIFECYCLE_MODE", "freshness")
try:
return KBLifecycleMode(raw)
except ValueError:
return KBLifecycleMode.FRESHNESS
关键状态
should_filter:当且仅当返回True,fact_store-query才需要对记忆做时效性和置信度过滤should_decay:当且仅当返回True,fact_store-query才需要对记忆的置信度做时间衰减处理should_tag:当且仅当返回True,research节点才需要给事实添加人类可读的时效性标签should_warn:当且仅当返回True,research节点才会鼓励提示词(prompt)措辞重新验证
Python
def should_filter(mode: KBLifecycleMode | None = None) -> bool:
"""
当应该根据事实的时效性来排除事实时,返回 True
"""
if mode is None:
mode = get_mode()
return mode in (KBLifecycleMode.FRESHNESS, KBLifecycleMode.LIFECYCLE)
def should_decay(mode: KBLifecycleMode | None = None) -> bool:
"""当需要对置信度分数进行基于时间的衰减时,返回 True
"""
if mode is None:
mode = get_mode()
return mode in (KBLifecycleMode.FRESHNESS, KBLifecycleMode.LIFECYCLE)
def should_tag(mode: KBLifecycleMode | None = None) -> bool:
"""
当需要给事实添加人类可读的时效性标签时,返回 True
"""
if mode is None:
mode = get_mode()
return mode != KBLifecycleMode.OFF
def should_warn(mode: KBLifecycleMode | None = None) -> bool:
"""
当提示词(prompt)的措辞应该鼓励重新验证,而不是禁止重新搜索时,返回 True"""
if mode is None:
mode = get_mode()
return mode != KBLifecycleMode.OFF
调用
初始化
KB 知识库应用主要在子图 reasearch_graph,我们在对应文件下实现初始化。
Python
# ── KB 单例(延迟初始化,在代理运行之间共享) ──────────────
_kb_store: FactStore | None = None
_kb_extractor: FactExtractor | None = None
_GENERATE_QUERIES = "generate_queries"
_WEB_SEARCH = "web_search"
_CRITIQUE = "critique"
def _get_kb_extractor() -> FactExtractor:
"""
获取 KB 事实生成器
:return:
"""
global _kb_extractor
if _kb_extractor is None:
_kb_extractor = FactExtractor()
return _kb_extractor
def _get_kb_store() -> FactStore | None:
"""
获取 KB 事实存储器
:return:
"""
global _kb_store
if _kb_store is None:
try:
_kb_store = FactStore()
logger.info("[KB] FactStor 连接到 Milvus")
except (KBConnectionError, KBConfigError) as exc:
logger.warning(f"[KB] FactStore 初始化失败(KB 不可用,将静默降级): {exc}")
_kb_store = False # type: ignore --- sentinel
except Exception as exc:
logger.warning(f"[KB] FactStore 初始化失败(未知错误,将静默降级): {exc}")
_kb_store = False # type: ignore --- sentinel
return _kb_store if _kb_store is not False else None
set
存储这里需要在 _web_search 节点,因为只有在这里才会返回搜索数据。这里要注意构造 KB 事实的数据源必须是原始可用 URL。
Python
# ── KB/知识库 存储 ────────────────────────────────────────────────
try:
store = _get_kb_store()
if store:
extractor = _get_kb_extractor()
topic = get_research_topic(state.get("messages", []))
facts = extractor.extract(summary, research_topic=topic)
if facts:
# 将短链接还原为真实 URL,避免 KB 中存储不可解析的过期引用
short2long = {v: k for k, v in long2shorts.items()}
for f in facts:
f["source_url"] = short2long.get(f["source_url"], f["source_url"])
f["research_topic"] = topic
store.add_facts(facts)
except (KBConnectionError, KBEmbeddingError) as exc:
logger.warning(f"[KB] 跳过存储(瞬时错误): {exc}")
except (KBConfigError, KBEmbeddingFatalError) as exc:
logger.error(f"[KB] 跳过存储(永久错误,需人工修复): {exc}")
except Exception as exc:
logger.warning(f"[KB] 跳过存储(未知错误): {exc}")
get
Code
对应地,在 _generate_search 节点优先读取 KB 记忆,搜索生成器只 针对 KB 记忆 known_facts 以外的知识生成相关搜索主题。 也就是基于当前的 KB 记忆去补充搜索。
ini
# ── KB/知识库检索 ──────────────────────────────────────────────
known_facts_text = ""
try:
store = _get_kb_store()
if store:
mode = get_mode()
topic = get_research_topic(state["messages"])
freshness = state.get("fresh_level", "medium")
max_age = FRESHNESS_MAX_AGE.get(freshness, 30) if should_filter(mode) else None
decay = should_decay(mode)
use_lifecycle = mode == KBLifecycleMode.LIFECYCLE
# 命中缓存
hits = store.query(
topic, top_k=20, min_confidence=0.6,
max_age_days=max_age,
decay=decay,
lifecycle_mode=use_lifecycle,
)
if hits:
facts_lines = []
for h in hits:
line = f"- [{h['confidence']:.0%}] {h['fact']}"
# 添加时效性标签
if should_tag(mode):
age_days = h.get("age_days", (time.time() - h["created_at"]) / 86400)
age_tag = (
"🕐 刚刚" if age_days < 1 else
f"{age_days:.0f}天前" if age_days < 30 else
f"{age_days / 30:.0f}个月前"
)
line += f" ({age_tag}, 来源: {h['source_url'][:60]})"
else:
line += f" (来源: {h['source_url'][:60]})"
facts_lines.append(line)
# 提示词加强处理:是否需要鼓励重新验证
if should_warn(mode):
header = "\n## 📚 知识库中已有的相关事实\n"
footer = "\n\n⚠️ 标记较早的事实可能已过时,请优先搜索获取最新信息。"
else:
header = "\n## 知识库中已有的相关事实(请勿重复搜索这些内容)\n"
footer = ""
known_facts_text = header + "\n".join(facts_lines) + footer
logger.info(f"[KB] 检索到 {len(hits)} 个facts 用作查询生成上下文")
except (KBConnectionError, KBEmbeddingError) as exc:
logger.warning(f"[KB] retrieval skipped(瞬时错误,下次可能恢复): {exc}")
except (KBConfigError, KBEmbeddingFatalError) as exc:
logger.error(f"[KB] retrieval skipped(永久错误,需人工修复): {exc}")
except Exception as exc:
logger.warning(f"[KB] retrieval skipped(未知错误): {exc}")
logger.info(f"[ResearchAgent] _generate_queries使用模型: {configuration.query_generator_model}")
agent = JsonAgent(model_id=configuration.query_generator_model, keys=SearchQueryList)
agent.set_step_prompt(query_writer_instructions)
result = agent.step(
current_date=get_current_date(),
research_topic=get_research_topic(state["messages"]),
number_queries=state["initial_search_query_count"],
research_proposal=state.get("plan", ""), # 这里加了人类确定的研究计划
known_facts = known_facts_text
)
Prompt改造
上面不难发现原来的提示词模板里多了一个 known_facts 参数,这里需要对原来的 query_writer_instructions 针对性补充。这里需要注意的是:
- 新增
known_facts参数,同时必须和## KB知识库已知事实(已验证的结构化数据,带时效标签)做显示绑定,让 Prompt 知道它是 KB 知识 - 新增基于
known_facts的拆解约束,拆出的搜索主题必须满足:- 禁止重复搜索 KB 已验证且时效充足的事实
- 对 KB 过时事实生成「更新验证」类查询
- 基于 KB 已知事实填补维度缺口
- 如果 KB 已知事实之间存在矛盾,或你认为已知事实可能不准确,生成用于交叉验证 的搜索主题,并在
rationale中说明
Python
uery_writer_instructions = """# 角色定义
你是一个主题研究拆解大师,你擅长将给定的研究主题拆解为不同的子主题,并给出这么拆解的理由,最后按照给定的格式输出结果
# 任务说明
你的任务是根据当前的研究主题决定多个用于网络搜索的标题,这些标题会被用于从网页搜集信息,并整合成一份专业的研究报告
# Instruction
- 针对当前的研究主题,你可以将其拆解成若干个搜索主题,每个搜索主题都应该是针对当前研究主题不同维度的切分
- 针对当前研究主题,最多不产生{number_queries}条搜索主题
- 你的搜索主题应该尽可能的广泛,如果研究主题本身就非常宽泛,则产出1条以上的搜索主题
- 每个搜索主题应该具备独立性,即不要同时产出多个相似或者耦合的搜索主题
- 搜索主题应该考虑时间,即除非研究主题要求,不然尽可能搜集近期的资料,当前时间是{current_date}
- KB知识库已知事实 使用规则(重要)
**本规则专用于下方 `# 背景信息 → ## KB知识库已知事实` 区块中的内容。**
**注意:请勿将`研究计划`中的假设、推论或目标,误认为是`KB知识库已知事实`。**
下述「已知事实」是从KB知识库中检索到的、与研究主题相关且已经过验证的事实。
你必须按以下规则使用它们:
1. **禁止重复搜索已覆盖且时效充足的事实**
对于已知事实中标注为「刚刚/X天前」(且属于 strategy / technology / historical 类别)的内容,不得生成与之相同或高度相似的搜索主题。
2. **对过时事实生成「更新验证」类查询**
对于已知事实中标注为「X个月前」或属于 market_data / product_info 类别的内容,你必须生成用于**获取最新数据**的搜索主题(例如在原主题前加「2025年最新」、「近期更新」等时间限定),而不是忽略它。
3. **基于已知事实填补维度缺口**
已知事实可能只覆盖了研究主题的部分维度。你必须识别尚未被已知事实覆盖的维度(如:市场规模、主要厂商、政策环境、技术瓶颈、未来趋势等),并针对这些**缺口维度**生成搜索主题。
4. **冲突处理**
如果已知事实之间存在矛盾,或你认为已知事实可能不准确,生成用于**交叉验证**的搜索主题,并在 rationale 中说明。
# Output Format
你生成的内容应该是一个标准的json格式的内容,并包含两个字端
<param>
<attribute>rationale</attribute>
<type>string</type>
<description>你的思考,即为什么要产出如下的几个搜索主题</description>
</param>
<param>
<attribute>query</attribute>
<type>List</type>
<description>用于做网络搜索的搜索主题</description>
</param>
下面是一个输出样例
```json
{
"rationale": "xxxx",
"query": ["搜索主题1", "搜索主题2", ...]
}
```
# 背景信息
## 研究课题
{research_topic}
## 研究计划(未经证实的假设与目标)
{research_proposal}
## KB知识库已知事实(已验证的结构化数据,带时效标签)
{known_facts}
# Output"""
KB 知识 🆚 search_cache 搜索缓存
对比
我们在前面实现了 search_cache 搜索缓存, 本章开头也有过简单的讨论。我们在完全实现玩 KB 知识记忆之后再系统地看下两个缓存方式。
首先,来看下加入二者后 DeepResearchSystem 的整体执行流程:
sql
[User Query]
│
▼
[1. KB Pre-retrieval] ──▶ 语义查询KB
│ ├─ Hit (High Confidence, Fresh): 直接作为已知事实传入Prompt
│ └─ Miss / Low Confidence: 继续下一步
│
▼
[2. Search Cache Check] ──▶ 精确匹配(prompt, count)
│ ├─ Hit: 直接返回Raw Snippets -> 跳至 [4. Extractor]
│ └─ Miss: 继续下一步
│
▼
[3. Web Search MCP] ──▶ 调用昂贵API
│
▼
[4. Extractor] ──▶ 从Raw Snippets中提取结构化Facts
│
▼
[5. KB Update] ──▶ 将新Fact存入KB (Add/Update)
│
▼
[6. Search Cache Set] ──▶ 将Raw Snippets存入Search Cache
│
▼
[7. Agent Reasoning] ──▶ 基于KB中的结构化事实生成报告
我们不难发现:
- Search Cache 保护的是 Web Search。它挡住了99%的重复搜索请求。
-
KB 保护的是 Agent 的智力水平。它挡住了幻觉和低质量信息。
-
Extractor 是连接二者的桥梁。它将 Search Cache 里的"原材料"加工成 KB 里的"成品"。
| 维度 | Search Cache (search_cache.py) |
Knowledge Base (KB System) |
|---|---|---|
| 心智模型 | 速度至上,成本优先,最终一致性。 核心思想是"空间换时间" | 质量至上,可信优先,事实一致性。 核心思想是"验证即真理",强调知识的准确性、可追溯性和长效性。 |
| 解决的痛点 | 1. API成本爆炸 :防止同一Prompt在毫秒级被并发请求多次调用昂贵的Web Search API。 2. 网络抖动/Redis故障 :通过本地内存缓存降级,保证单实例在Redis宕机时仍能工作。 3. 数据分裂 :多实例部署时,通过Redis共享缓存,并通过回填机制解决Redis重启后各实例数据不一致的问题。 | 1. 大模型幻觉 :防止Agent编造事实,所有结论必须有据可依。 2. 知识断层 :避免每次启动Agent都从零开始,实现跨会话、跨项目的知识积累。 3. 信息过时:通过TTL和置信度衰减,确保决策基于最新信息,而非陈旧的训练数据。 |
| 数据本质 | Raw Data(原材料) : 未经清洗的网页摘要(Title, Snippet, URL)。包含噪音、广告、过时信息。 | Refined Knowledge(黄金) : 经过Extractor提取的结构化原子事实(Fact Text, Confidence, Category)。 |
| 匹配模式 | Key精确匹配 : MD5(prompt + count)。 |
Embedding 语义匹配 |
| TTL设计 | 极短 (Short-lived) : 默认TTL=7200秒(2小时)。 网页内容瞬息万变,长时间缓存等于主动使用脏数据。 | 分级长期 (Long-lived) : 基于CATEGORY_TTL。 历史事件永不过期,市场数据7天过期。符合知识本身的客观寿命。 |
| 一致性模型 | 最终一致性 : 通过get_cached中的回填机制(Backfill) 实现。本地缓存可能比Redis新,此时会触发回写,保证Redis最终是最新的。 |
强一致性 : 写入即真理。冲突时通过置信度或来源权重仲裁。 |
| 失效策略 | 被动过期 : 设定失效时间 | 主动治理 : 置信度衰减、新旧事实冲突消解 |
| 故障处理 | 优雅降级 : Redis挂了 -> 降级为本地内存缓存 -> 继续服务。 | **熔断与告警 ** : KB挂了 -> Agent主流程不影响(长步骤高成本) -> 触发告警,人工介入。 |
🤔思考🤔🤔
- KB 与 Search Cache 的联动失效怎么处理 ?
- 当 KB 更新了一条高置信度的 Fact 时(例如修正了一个错误数据),理论上应该清除 Search Cache 中所有与该 Fact 相关的Key。否则走到 Web_search 时拿缓存,又拿到了低置信度数据 。
- Cache 匹配模式是 key,这显然很难做到...
- 目前我能想到的就是:
- 直接全放弃 Cache ,重头搞起。(好像略有点浪费。。。)
- Prompt 加强: "请忽略缓存中的旧数据,使用KB中的最新事实"
更多 AI 技术干货 请订阅 AI技术手札专栏。