DeepResearchSystem 0x08:KB 知识记忆

回顾 -> 问题引出

  1. DeepRearchSystem 0x00:初识
  2. DeepRearchSystem 0x01:Agent 基础
  3. DeepRearchSystem 0x02:Graph 构建
  4. DeepRearchSystem 0x03:HITL
  5. DeepResearchSystem 0x04:MAS 进阶
  6. DeepResearchSystem 0x06:LLM as Judge
  7. DeepResearchSystem 0x07:查询缓存

上一章] 实现了 DeepRearchSystem 的搜索缓存。解决了短时间内同样主题重复请求的问题。现在又遇到了新的挑战:

  1. search_cache 本身解决的是短时间内的重复请求问题,我们设计的 TTL 也是小时级别。那么如果是第二天出现了同样的 query 该怎么办呢?是否只能按正常流程重新跑一遍。

  2. search_cache 的 key设计是:(prompt, count),本质是 query-prompt 的精确匹配。比如 result1 = query("AI芯片销量"), 下次 query("GPU出货量"):因为 prompt 不能匹配,就必须重新跑一遍。但是呢,我们都知道 "AI芯片销量" 和 "GPU出货量"是息息相关的,上次的检索结果对这次查询完全有可复用的资料

3.search_cache 解决的是短期内并发请求的重复调用问题。一旦跨会话/跨项目,DeepRearchSystem 每次只能重来。Agent 是有能力但只会蛮干,就像 "鱼的记忆只有7s",如果能让 DeepRearchSystem 像人一样有记忆就好了。

怎么办?(思考方向)

其实,到这里要做的事情就有点清晰了:要让 Agent 具备长期记忆 ,当 query 来临时先查自己的记忆,记忆里没有再查短期缓存,短期缓存没有再走搜索流程重新沉淀。沉淀后的知识再反哺记忆。概括起来需要具备:

  1. 具备长期记忆,可以优先从记忆中检索

  2. 不局限于精确匹配,而是支持语义匹配

咦,听起来是不是像 RAG 知识库。确实有点像,但还是有点差别的。至于哪些地方有差别,我们先去设计和实现之后再回来看待这个问题。

干他 (架构设计)

事实提取器

extractor: 事实提取器。从搜索摘要中精准剥离出离散、可验证的事实,并自动打标(置信度、类别等)

  • 置信度:对事实进行验证,记忆只存储经验证的有置信度打分的数据
  • 类别:用于 lifecycle 对数据的 TTL 管理,不同类型数据的过期时间不同

事实记忆器

fact_store: 事实存储器。基于 Milvus 的向量数据库存储。

  • vector 选型:支持高效的语义检索

TTL 架构

lifecycle: TTL 规则器。决定哪些类型的数据有效期短,哪些类型有效期长。用来保证知识新鲜度。

年龄阈值

FRESHNESS_MAX_AGE: 年龄阈值(天)

  • high: 7。7 天有效期
  • medium: 30
  • low: 180

生命周期

KBLifecycleMode: KB模式

  • off/关闭:不进行过滤、不衰减、不添加年龄标记。
  • inform/通知:添加年龄标记并更改措辞 ,但不进行过滤或置信度衰减
    • 开关打开,为搜索数据增加 age_days时效时效时效
    • 根据 age_days 参数修改 Prompt:
      • "知识库中已有的相关事实(请勿重复搜索这些内容)"
      • "标记较早的事实可能已过时,请优先搜索获取最新信息"
  • freshness/新鲜度:Plan LLM判定 → 统一的年龄过滤和置信度衰减。
    • 置信度衰减 :随着时间推移记忆数据的置信度理应衰减
      • 曲线衰减:decay = e^(-lambda * age)
      • 类别敏感度:对应 market_datahistorical 分布不同
  • lifecycle/生命周期:事实类别 → 按类别进行生命周期时间 (TTL) 过滤和衰减。
    • market_data 7, 市场数据------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 项目中的向量化流程一毛一样。这里要注意几点:

  1. base_url 拼接要注意检查给定厂商是否带了 v1

  2. 做好异常捕获和处理

    • 实际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中的结构化事实生成报告

我们不难发现:

  1. 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主流程不影响(长步骤高成本) -> 触发告警,人工介入。

🤔思考🤔🤔

  1. KB 与 Search Cache 的联动失效怎么处理 ?
  • 当 KB 更新了一条高置信度的 Fact 时(例如修正了一个错误数据),理论上应该清除 Search Cache 中所有与该 Fact 相关的Key。否则走到 Web_search 时拿缓存,又拿到了低置信度数据
    • Cache 匹配模式是 key,这显然很难做到...
    • 目前我能想到的就是:
      • 直接全放弃 Cache ,重头搞起。(好像略有点浪费。。。)
      • Prompt 加强: "请忽略缓存中的旧数据,使用KB中的最新事实"

更多 AI 技术干货 请订阅 AI技术手札专栏

相关推荐
程序员黑豆3 小时前
Java 注释详解:单行、多行与文档注释的完整指南
java·前端·ai编程
飞哥数智坊5 小时前
实测7套 Code Agent组合:最终效果,真不只取决于模型
ai编程
飞哥数智坊5 小时前
同一个 Agent,为什么有人越用越顺,有人却一直在返工?
agent
东小西5 小时前
番外篇二:《不到十行代码,我用ReactAgen搭了个会自己调工具的 Agent》
openai·ai编程
北斗落凡尘6 小时前
LangGraph 入门实战(2)
python·langchain
JouYY7 小时前
大模型底层学习(三)-从零训练一个 BPE 分词器
架构·llm·agent
jufeng13078 小时前
【系列:手搓自主 AI Agent:Hermes 架构原理剖析 · 第 1 篇】
人工智能·python·架构·agent
kyriewen8 小时前
我用Claude Code两天干完了团队两周的排期——周报发出去那一刻我就后悔了
前端·javascript·ai编程
东小西10 小时前
番外篇一:《Spring AI Alibaba 到底是啥?一张图理清两者关系》
openai·ai编程