从零到一构建临床文献智能研究Agent(二):LangGraph 多智能体编排

0 阅读17分钟

在上一篇中,我们完成了项目环境搭建和前后端通信验证。本篇将深入后端核心——使用 LangGraph 构建多智能体状态图,实现问题拆解、并行文献检索、证据评估和智能综合的完整流水线。

本篇目标

读完这篇文章,你将掌握:

  • 如何设计 AgentState 来管理多智能体之间的数据流
  • LangGraph 的 Reducer 机制——让并行结果自动归并
  • 使用 Send API 实现动态并行分支(fan-out)
  • 四个智能体节点的职责划分和实现思路
  • 状态图的构建、编排与编译

核心概念:为什么需要"状态图"?

在传统的 AI 应用中,我们通常只有一个 LLM 调用链——输入进去,输出出来。但对于临床文献研究这种复杂任务,一次性调用远远不够:

  1. 用户的临床问题可能很宏观,需要拆解为多个可检索的子问题
  2. 多个子问题应该并行检索,而不是串行等待
  3. 检索到的数十篇文献需要独立评估打分
  4. 最终需要综合分析所有证据,生成有引用的结论

这就是一个典型的有向无环图(DAG) 执行流程。LangGraph 的 StateGraph 正是为这种场景设计的——它让你用图的方式定义节点(智能体)和边(数据流),并内建了并行执行和状态管理能力。

第一步:设计 AgentState(状态模式)

AgentState 是整个系统的"数据总线"——每个智能体节点从中读取输入,执行完后将结果写回。设计一个好的 State,是多智能体系统最关键的第一步。

完整定义

创建 backend/src/state.py

import operator
from typing import Annotated, TypedDict
​
​
def _last_value(existing: str, new: str) -> str:
    """自定义 Reducer:保留最后一个值"""
    return new
​
​
class AgentState(TypedDict):
    # 用户输入
    user_query: str                                    # 原始临床问题
    language: str                                      # "zh" 或 "en"
​
    # 问题拆解结果
    sub_questions: list[str]                          # 拆解后的子问题列表
​
    # 文献检索结果(核心:使用加法 Reducer)
    retrieval_results: Annotated[list, operator.add]  # 并行检索的论文列表
​
    # 证据评估结果
    evaluated_papers: list[EvaluatedPaper]            # 评分后的论文
​
    # 综合输出
    synthesis: str                                     # 最终证据摘要
    citations: list[Citation]                         # 引用列表
​
    # 控制字段
    retry_count: int                                  # 重试计数器
    current_step: Annotated[str, _last_value]        # 当前执行步骤

关键设计解析

1. Reducer 机制——并行结果自动归并

这是 LangGraph 最精妙的设计之一。看这行代码:

retrieval_results: Annotated[list, operator.add]

Annotated[list, operator.add] 的含义是:当多个节点同时向 retrieval_results 写入数据时,使用列表拼接+)来合并结果,而不是覆盖。

具体场景:假设用户问了一个复杂问题,被拆解成 3 个子问题,3 个检索器并行执行:

检索器 1 返回:{"retrieval_results": [论文A, 论文B, ...]}
检索器 2 返回:{"retrieval_results": [论文C, 论文D, ...]}
检索器 3 返回:{"retrieval_results": [论文E, 论文F, ...]}

LangGraph 自动调用 operator.add,最终状态变为:

state["retrieval_results"] = [论文A, 论文B, ..., 论文C, 论文D, ..., 论文E, 论文F, ...]

无需手动写任何合并代码,框架帮你搞定。

2. 自定义 Reducer:_last_value

def _last_value(existing: str, new: str) -> str:
    return new
​
current_step: Annotated[str, _last_value]

current_step 用于追踪当前执行到了哪一步("decomposing"、"retrieving"、"evaluating"、"synthesizing")。当多个节点并行执行时,只保留最后更新的值。这个字段主要用于向前端推送进度信息。

3. 没有 Reducer 的字段

user_querysub_questionssynthesis 这些字段没有使用 Reducer,意味着后写入的值会直接覆盖前一个值——这正是我们期望的行为,因为它们在整个流程中只会被写入一次。

第二步:定义数据模型

在编写智能体之前,先定义它们之间传递的数据结构。

创建 backend/src/models.py

from pydantic import BaseModel
​
​
class Paper(BaseModel):
    """PubMed 论文数据模型"""
    pmid: str                   # PubMed 唯一标识
    title: str                  # 论文标题
    abstract: str               # 摘要
    authors: list[str]          # 作者列表
    journal: str                # 期刊名称
    pub_date: str               # 发表日期(YYYY 或 YYYY-MM)
    doi: str = ""               # DOI 号
    pub_type: str = ""          # 研究类型(如 "Randomized Controlled Trial")
​
​
class EvaluatedPaper(BaseModel):
    """评估后的论文"""
    paper: Paper
    evidence_score: float       # 综合证据评分(0-100)
    evidence_level: str         # 证据等级(Level I-IV)
    study_design_score: int     # 研究设计得分
    recency_score: int          # 时效性得分
    sample_size_score: int      # 样本量得分
    relevance_score: int        # 相关性得分
​
​
class Citation(BaseModel):
    """引用信息"""
    claim: str                  # 支持的论断
    doi: str                    # DOI 引用
    pmid: str                   # PubMed ID
    evidence_level: str         # 证据等级
    source_title: str           # 来源论文标题

设计思路

数据模型的设计遵循了渐进增强的思路:

  • Paper:原始文献数据,来自 PubMed API
  • EvaluatedPaper:在 Paper 基础上增加了评分信息
  • Citation:从评估后的论文中提取的引用信息

每个模型对应流水线中的一个阶段,清晰反映了数据的流转过程。

第三步:实现四个智能体节点

节点 1:问题拆解器(Query Decomposer)

职责:将用户的复杂临床问题拆解为 2-5 个可直接在 PubMed 上检索的子问题。

创建 backend/src/agents/query_decomposer.py

import json
import re
​
from langchain_openai import ChatOpenAI
from langchain_core.messages import SystemMessage, HumanMessage
​
from src.config import settings
from src.state import AgentState
from src.utils import detect_language
​
​
async def decompose_query(state: AgentState, llm: ChatOpenAI = None) -> dict:
    """将复杂临床问题拆解为可检索的子问题"""
    user_query = state["user_query"]
    language = state.get("language") or detect_language(user_query)
​
    if llm is None:
        llm = ChatOpenAI(
            base_url=settings.deepseek_base_url,
            api_key=settings.deepseek_api_key,
            model=settings.deepseek_model,
            temperature=0.1,
        )
​
    system_prompt = """You are a clinical research query decomposition expert.
Your task is to break down complex clinical questions into 2-5 specific,
searchable sub-questions for PubMed literature search.
​
Rules:
- Each sub-question should be specific enough to search on PubMed
- Use medical terminology appropriate for literature search
- Cover different aspects: efficacy, safety, mechanism, population, comparison
- Output MUST be valid JSON: {"sub_questions": ["q1", "q2", ...]}
- If the question is already specific enough, return it as a single sub-question
- Always output sub-questions in English for PubMed search"""
​
    response = await llm.ainvoke([
        SystemMessage(content=system_prompt),
        HumanMessage(content=f"Decompose this clinical question: {user_query}"),
    ])
​
    # 解析 LLM 输出的 JSON
    sub_questions = _parse_sub_questions(response.content, user_query)
​
    return {
        "sub_questions": sub_questions,
        "language": language,
        "current_step": "decomposing",
    }
​
​
def _parse_sub_questions(content: str, fallback: str) -> list[str]:
    """从 LLM 输出中提取子问题列表,失败则回退到原始问题"""
    try:
        # 尝试从 markdown 代码块中提取 JSON
        json_match = re.search(r'```(?:json)?\s*(.*?)```', content, re.DOTALL)
        if json_match:
            data = json.loads(json_match.group(1).strip())
        else:
            data = json.loads(content)
​
        questions = data.get("sub_questions", [])
        if questions and isinstance(questions, list):
            return questions
    except (json.JSONDecodeError, AttributeError, KeyError):
        pass
​
    return [fallback]

要点解析

  1. Prompt 工程:系统提示要求 LLM 遵循 PICO 框架(Population、Intervention、Comparator、Outcome)来拆解问题。通过明确要求 JSON 输出格式,减少解析失败的概率。
  2. 健壮的 JSON 解析:LLM 可能返回 markdown 代码块包裹的 JSON,也可能返回裸 JSON。_parse_sub_questions 同时处理两种情况。
  3. 优雅降级:如果 JSON 解析失败,直接将原始问题作为唯一的子问题返回,保证流水线不中断。
  4. 双语支持:通过 detect_language() 检测输入语言,但子问题统一输出为英文——因为 PubMed 是英文数据库。

节点 2:文献检索器(Literature Retriever)

职责:调用 PubMed E-utilities API 检索文献,支持自动重试和查询放宽。

在实现检索器之前,先实现 PubMed 工具层。

PubMed 工具封装

创建 backend/src/tools/pubmed_tool.py

import xml.etree.ElementTree as ET
​
import httpx
​
from src.config import settings
​
ESEARCH_URL = "https://eutils.ncbi.nlm.nih.gov/entrez/eutils/esearch.fcgi"
EFETCH_URL = "https://eutils.ncbi.nlm.nih.gov/entrez/eutils/efetch.fcgi"
​
​
async def search_pubmed(
    query: str,
    max_results: int = None,
    min_date: str = None,
) -> list[dict]:
    """两步检索:eSearch 获取 PMID → eFetch 获取详细信息"""
    max_results = max_results or settings.pubmed_max_results
    min_date = min_date or settings.pubmed_min_date
​
    async with httpx.AsyncClient(timeout=30.0) as client:
        # Step 1: eSearch — 搜索获取 PMID 列表
        search_params = {
            "db": "pubmed",
            "term": query,
            "retmode": "json",
            "retmax": max_results,
            "sort": "relevance",
            "mindate": min_date,
            "maxdate": "3000",
            "datetype": "pdat",
        }
​
        # 如果配置了 NCBI API Key,附加到请求中(提升速率限制)
        if settings.ncbi_api_key:
            search_params["api_key"] = settings.ncbi_api_key
​
        resp = await client.get(ESEARCH_URL, params=search_params)
        resp.raise_for_status()
        search_data = resp.json()
​
        pmids = search_data.get("esearchresult", {}).get("idlist", [])
        if not pmids:
            return []
​
        # Step 2: eFetch — 批量获取论文详情
        fetch_params = {
            "db": "pubmed",
            "id": ",".join(pmids),
            "retmode": "xml",
        }
        if settings.ncbi_api_key:
            fetch_params["api_key"] = settings.ncbi_api_key
​
        resp = await client.get(EFETCH_URL, params=fetch_params)
        resp.raise_for_status()
​
        return _parse_pubmed_xml(resp.text)
​
​
def _parse_pubmed_xml(xml_text: str) -> list[dict]:
    """解析 PubMed XML 响应,提取论文关键字段"""
    root = ET.fromstring(xml_text)
    papers = []
​
    for article in root.findall(".//PubmedArticle"):
        # ... 解析 PMID、标题、摘要、作者、期刊、日期、DOI、研究类型
        paper = {
            "pmid": _get_text(article, ".//PMID"),
            "title": _get_text(article, ".//ArticleTitle"),
            "abstract": _get_abstract(article),
            "authors": _get_authors(article),
            "journal": _get_text(article, ".//Journal/Title"),
            "pub_date": _get_pub_date(article),
            "doi": _get_doi(article),
            "pub_type": _get_pub_type(article),
        }
        papers.append(paper)
​
    return papers

PubMed E-utilities 采用两步检索策略:

  1. eSearch:发送检索词,返回匹配的 PMID 列表
  2. eFetch:用 PMID 批量获取论文的完整元数据(XML 格式)

这种两步策略是 NCBI 官方推荐的做法,能够充分利用 PubMed 的相关性排序能力。

检索器节点实现

创建 backend/src/agents/literature_retriever.py

from src.models import Paper
from src.tools.pubmed_tool import search_pubmed
from src.config import settings


async def retrieve_literature(sub_question: str) -> list[Paper]:
    """检索文献,支持自动重试与查询放宽"""
    all_papers = []
    seen_pmids = set()
    retry = 0

    while retry <= settings.pubmed_retry_max:
        # 根据重试次数逐步放宽检索策略
        query = _build_query(sub_question, retry)
        raw_papers = await search_pubmed(query)

        for p in raw_papers:
            if p["pmid"] not in seen_pmids:
                seen_pmids.add(p["pmid"])
                all_papers.append(Paper(**p))

        # 结果足够多则停止重试
        if len(all_papers) >= 5:
            break

        retry += 1

    return all_papers


def _build_query(question: str, retry: int) -> str:
    """根据重试次数构建不同宽度的检索词"""
    if retry == 0:
        return question  # 首次:使用完整问题
    elif retry == 1:
        return question  # 第二次:移除出版类型限制(在 search_pubmed 中处理)
    else:
        # 第三次:只取前几个关键词,大幅放宽检索
        words = question.split()
        return " ".join(words[:4])

自动重试策略是检索器的亮点设计:

重试次数策略原因
第 0 次完整问题检索最精确匹配
第 1 次移除出版类型限制扩大检索范围
第 2 次只取前 4 个关键词大幅放宽,获取泛相关文献

每次重试都会去重(通过 seen_pmids 集合),避免重复论文。当累计结果 ≥ 5 篇时提前终止,兼顾效率和覆盖率。

节点 3:证据评估器(Evidence Evaluator)

职责:基于循证医学的证据等级体系,对每篇论文进行多维度打分。

创建 backend/src/agents/evidence_evaluator.py

from datetime import datetime

from src.models import Paper, EvaluatedPaper


def evaluate_papers(
    papers: list[Paper],
    sub_question: str = "",
) -> list[EvaluatedPaper]:
    """评估论文质量,返回按证据评分排序的结果"""
    evaluated = []

    for paper in papers:
        design_score = _score_study_design(paper.pub_type)
        recency_score = _score_recency(paper.pub_date)
        sample_score = 50   # 占位:待实现样本量提取
        relevance_score = 50  # 占位:待实现语义相关性评分

        # 加权综合评分
        evidence_score = (
            design_score * 0.35 +
            recency_score * 0.25 +
            sample_score * 0.20 +
            relevance_score * 0.20
        )

        evaluated.append(EvaluatedPaper(
            paper=paper,
            evidence_score=round(evidence_score, 1),
            evidence_level=_classify_level(paper.pub_type),
            study_design_score=design_score,
            recency_score=recency_score,
            sample_size_score=sample_score,
            relevance_score=relevance_score,
        ))

    # 按证据评分降序排列
    evaluated.sort(key=lambda x: x.evidence_score, reverse=True)
    return evaluated

评分维度详解

维度一:研究设计得分(权重 35%)

STUDY_DESIGN_SCORES = {
    "Meta-Analysis": 100,
    "Systematic Review": 100,
    "Randomized Controlled Trial": 85,
    "Clinical Trial": 70,
    "Cohort Study": 60,
    "Observational Study": 60,
    "Case-Control": 40,
    "Case Reports": 20,
    "Review": 50,
}

这个评分体系直接映射了循证医学的经典证据金字塔

维度二:时效性得分(权重 25%)

def _score_recency(pub_date: str) -> int:
    """论文越新,分数越高"""
    try:
        year = int(pub_date[:4])
        age = datetime.now().year - year
        if age <= 2:
            return 100  # 近 2 年
        elif age <= 5:
            return 70   # 3-5 年
        elif age <= 10:
            return 40   # 6-10 年
        else:
            return 20   # 10 年以上
    except (ValueError, IndexError):
        return 50

在快速发展的临床领域,最新的证据往往更有参考价值。我们将 2020 年以后的文献作为最低检索门槛(在 PubMed 查询参数中设置了 min_date),并在评分中进一步区分。

维度三和四:样本量和相关性(各 20%)

目前使用占位值 50 分。后续迭代中将分别实现:

  • 样本量评分:从摘要中提取样本量信息(NLP 任务)
  • 相关性评分:使用嵌入模型计算论文与查询的语义相似度

证据等级分类

def _classify_level(pub_type: str) -> str:
    """将论文分为四个证据等级"""
    pub_type_lower = pub_type.lower()
    if any(t in pub_type_lower for t in ["meta-analysis", "systematic review"]):
        return "Level I"
    elif any(t in pub_type_lower for t in ["randomized", "clinical trial"]):
        return "Level II"
    elif any(t in pub_type_lower for t in ["cohort", "observational", "case-control"]):
        return "Level III"
    else:
        return "Level IV"
等级对应研究类型可信度
Level IMeta-Analysis、Systematic Review最高
Level IIRCT、Clinical Trial
Level IIICohort、Observational、Case-Control中等
Level IVCase Report、Review、其他较低

节点 4:证据综合器(Synthesizer)

职责:将评估后的文献综合为结构化的证据摘要,带引用和免责声明。

创建 backend/src/agents/synthesizer.py

from langchain_openai import ChatOpenAI
from langchain_core.messages import SystemMessage, HumanMessage

from src.config import settings
from src.state import AgentState
from src.models import Citation


async def synthesize_evidence(state: AgentState, llm: ChatOpenAI = None) -> dict:
    """综合所有证据,生成带引用的结构化摘要"""
    if llm is None:
        llm = ChatOpenAI(
            base_url=settings.deepseek_base_url,
            api_key=settings.deepseek_api_key,
            model=settings.deepseek_model,
            temperature=0.1,
        )

    language = state.get("language", "en")
    evaluated_papers = state.get("evaluated_papers", [])
    sub_questions = state.get("sub_questions", [])

    # 取评分最高的 20 篇论文
    top_papers = sorted(
        evaluated_papers,
        key=lambda x: x.evidence_score,
        reverse=True,
    )[:20]

    # 构建论文摘要信息
    papers_context = _format_papers_for_prompt(top_papers)

    # 根据语言选择提示词
    system_prompt = _get_system_prompt(language)

    user_prompt = f"""Original question: {state['user_query']}

Sub-questions investigated:
{chr(10).join(f'- {sq}' for sq in sub_questions)}

Evidence from {len(top_papers)} top-ranked papers:
{papers_context}

Please synthesize the evidence above into a comprehensive summary."""

    response = await llm.ainvoke([
        SystemMessage(content=system_prompt),
        HumanMessage(content=user_prompt),
    ])

    # 添加免责声明
    disclaimer = _get_disclaimer(language)
    synthesis = response.content + "\n\n" + disclaimer

    # 提取引用信息
    citations = _extract_citations(top_papers)

    return {
        "synthesis": synthesis,
        "citations": citations,
        "current_step": "synthesizing",
    }


def _format_papers_for_prompt(papers) -> str:
    """将论文格式化为 LLM 可读的文本"""
    lines = []
    for i, ep in enumerate(papers, 1):
        p = ep.paper
        lines.append(
            f"[{i}] ({ep.evidence_level}) {p.title}\n"
            f"    DOI: {p.doi} | PMID: {p.pmid}\n"
            f"    Type: {p.pub_type} | Date: {p.pub_date} | Score: {ep.evidence_score}\n"
            f"    Abstract: {p.abstract[:300]}..."
        )
    return "\n\n".join(lines)

综合器的 Prompt 设计有几个关键要求:

  1. 每个论断必须引用 DOI[DOI: 10.xxxx/yyyy],确保可追溯
  2. 按子问题分组呈现:帮助读者快速定位感兴趣的部分
  3. 显式处理矛盾证据:当不同研究结论相悖时,必须说明
  4. 自动附加免责声明:明确系统输出不构成医疗建议
def _get_disclaimer(language: str) -> str:
    if language == "zh":
        return ("---\n⚠️ **免责声明**:本报告由 AI 系统自动生成,"
                "仅供学术参考,不构成任何医疗建议。"
                "临床决策请遵循专业医生的指导和最新临床指南。")
    return ("---\n⚠️ **Disclaimer**: This report was generated by an AI system "
            "for academic reference only and does not constitute medical advice. "
            "Clinical decisions should follow professional medical guidance.")

第四步:构建状态图

四个智能体节点准备就绪,现在将它们用 LangGraph 的 StateGraph 编排起来。

创建 backend/src/graph.py

from langchain_openai import ChatOpenAI
from langgraph.graph import StateGraph, START, END, Send

from src.config import settings
from src.state import AgentState
from src.agents.query_decomposer import decompose_query
from src.agents.literature_retriever import retrieve_literature
from src.agents.evidence_evaluator import evaluate_papers
from src.agents.synthesizer import synthesize_evidence


def build_graph(llm: ChatOpenAI = None):
    """构建并编译多智能体状态图"""

    # ─── 节点定义 ───────────────────────────────────

    async def decomposer_node(state: AgentState) -> dict:
        return await decompose_query(state, llm=llm)

    async def retriever_node(state: AgentState) -> dict:
        sub_q = state.get("_current_sub_question", "")
        papers = await retrieve_literature(sub_q)
        return {"retrieval_results": papers}

    async def evaluator_node(state: AgentState) -> dict:
        papers = state["retrieval_results"]
        evaluated = evaluate_papers(papers, sub_question=state["user_query"])
        return {"evaluated_papers": evaluated, "current_step": "evaluating"}

    async def synthesizer_node(state: AgentState) -> dict:
        return await synthesize_evidence(state, llm=llm)

    # ─── 并行路由函数 ─────────────────────────────────

    def route_to_retrievers(state: AgentState):
        """使用 Send API 实现动态并行分支"""
        sub_questions = state["sub_questions"]
        if not sub_questions:
            return [Send("retriever", {
                **state,
                "_current_sub_question": state["user_query"],
            })]
        return [
            Send("retriever", {**state, "_current_sub_question": sq})
            for sq in sub_questions
        ]

    # ─── 构建状态图 ─────────────────────────────────

    graph = StateGraph(AgentState)

    # 添加节点
    graph.add_node("decomposer", decomposer_node)
    graph.add_node("retriever", retriever_node)
    graph.add_node("evaluator", evaluator_node)
    graph.add_node("synthesizer", synthesizer_node)

    # 定义边
    graph.add_edge(START, "decomposer")
    graph.add_conditional_edges("decomposer", route_to_retrievers, ["retriever"])
    graph.add_edge("retriever", "evaluator")
    graph.add_edge("evaluator", "synthesizer")
    graph.add_edge("synthesizer", END)

    # 编译
    return graph.compile()

核心:Send API 实现并行 Fan-Out

route_to_retrievers 是整个系统最关键的函数。让我们仔细拆解:

def route_to_retrievers(state: AgentState):
    sub_questions = state["sub_questions"]
    return [
        Send("retriever", {**state, "_current_sub_question": sq})
        for sq in sub_questions
    ]
  1. Send("retriever", new_state) :创建一个指令,告诉 LangGraph "用这个 state 启动一个 retriever 节点"
  2. 列表推导式:为每个子问题生成一个 Send,意味着同时启动 N 个 retriever
  3. _current_sub_question:通过向 state 注入临时字段,告知每个检索器它该检索哪个子问题
  4. add_conditional_edges:将这个路由函数注册为 decomposer → retriever 之间的条件边

LLM 初始化

def create_llm() -> ChatOpenAI:
    """创建 LLM 实例(使用 DeepSeek API)"""
    return ChatOpenAI(
        base_url=settings.deepseek_base_url,
        api_key=settings.deepseek_api_key,
        model=settings.deepseek_model,
        temperature=0.1,  # 低温度确保输出稳定性
    )

这里使用 langchain-openaiChatOpenAI,因为 DeepSeek 的 API 完全兼容 OpenAI 格式——只需替换 base_url 即可,零适配成本。

第五步:辅助模块

语言检测

创建 backend/src/utils.py

import re


def detect_language(text: str) -> str:
    """检测文本语言:中文返回 'zh',否则返回 'en'"""
    if re.search(r"[\u4e00-\u9fff]", text):
        return "zh"
    return "en"

通过检测 Unicode 中文字符范围(U+4E00 至 U+9FFF)来判断。虽然简单,但对我们的场景足够用——用户输入要么是中文,要么是英文。

整体数据流回顾

让我们用一个完整的例子来回顾数据流。用户输入:

"SGLT2 抑制剂治疗射血分数保留型心衰的最新证据是什么?"

1. 语言检测

detect_language("SGLT2 抑制剂治疗射血分数保留型心衰的最新证据是什么?")
# → "zh"(检测到中文字符)

2. 问题拆解(Decomposer)

LLM 将问题拆解为:

{
  "sub_questions": [
    "SGLT2 inhibitors efficacy in HFpEF randomized controlled trials",
    "SGLT2 inhibitors safety profile in heart failure with preserved ejection fraction",
    "Mechanism of SGLT2 inhibitors in HFpEF pathophysiology"
  ]
}

注意:子问题统一转为英文,因为 PubMed 是英文数据库。

3. 并行检索(Retriever × 3)

子问题 1 → PubMed 检索 → [论文A, 论文B, ...]  (约20篇)
子问题 2 → PubMed 检索 → [论文C, 论文D, ...]  (约20篇)
子问题 3 → PubMed 检索 → [论文E, 论文F, ...]  (约20篇)

operator.add Reducer 自动合并 → retrieval_results 包含约 60 篇论文(去重后)。

4. 证据评估(Evaluator)

对每篇论文计算综合评分:

论文A: 设计85×0.35 + 时效100×0.25 + 样本50×0.20 + 相关50×0.20 = 74.8 (Level II)
论文B: 设计100×0.35 + 时效70×0.25 + 样本50×0.20 + 相关50×0.20 = 72.5 (Level I)
...

按评分降序排列。

5. 证据综合(Synthesizer)

取 Top 20 论文,LLM 生成中文证据摘要:

## SGLT2 抑制剂治疗 HFpEF 的证据综述

### 疗效证据
EMPEROR-Preserved 试验表明,恩格列净显著降低 HFpEF 患者的
心血管死亡和心衰住院复合终点风险 [DOI: 10.1056/NEJMoa2107038]...

### 安全性数据
...

### 作用机制
...

---
⚠️ **免责声明**:本报告由 AI 系统自动生成,仅供学术参考...

关键设计决策小结

决策选择原因
状态管理TypedDict + Reducer类型安全 + 自动合并并行结果
并行策略Send API动态数量的并行分支,不需要预定义
评分模型加权多维评分可解释、可扩展,符合循证医学体系
LLM 调用异步 ainvoke不阻塞事件循环,支持高并发
JSON 解析正则提取 + 降级兼容 LLM 输出的不确定性
子问题语言统一英文PubMed 是英文数据库

本地验证

创建一个简单的测试脚本来验证图的构建:

# test_graph.py
import asyncio
from src.graph import build_graph, create_llm

async def main():
    llm = create_llm()
    graph = build_graph(llm=llm)

    # 打印图结构
    print("Graph nodes:", list(graph.nodes))
    print("Graph built successfully!")

    # 如果你已配置好 API Key,可以运行完整测试
    # result = await graph.ainvoke({
    #     "user_query": "What is the latest evidence on SGLT2 inhibitors for HFpEF?",
    #     "language": "en",
    #     "sub_questions": [],
    #     "retrieval_results": [],
    #     "evaluated_papers": [],
    #     "synthesis": "",
    #     "citations": [],
    #     "retry_count": 0,
    #     "current_step": "",
    # })
    # print(result["synthesis"])

asyncio.run(main())
cd backend
uv run python test_graph.py
# 预期输出:
# Graph nodes: ['decomposer', 'retriever', 'evaluator', 'synthesizer']
# Graph built successfully!

小结

在这篇文章中,我们完成了后端核心的搭建:

  • 设计了 AgentState:使用 TypedDict + Reducer 实现类型安全的状态管理,operator.add 解决并行结果合并
  • 实现了四个智能体:问题拆解器(LLM)、文献检索器(PubMed API)、证据评估器(规则引擎)、综合器(LLM)
  • 使用 Send API 实现并行 Fan-Out:动态数量的检索器并行执行,无需手动协调
  • 建立了证据评估体系:基于循证医学金字塔的四维加权评分
  • 构建了完整的状态图:从 START 到 END 的有向无环图,编译为可执行的 LangGraph 应用

下一篇预告:我们将实现 FastAPI 后端的 SSE(Server-Sent Events)流式推送接口,让前端能够实时展示每个智能体的执行进度和中间结果。


本系列文章基于实际开发过程编写,旨在分享多智能体系统的设计思路与工程实践。如有问题或建议,欢迎交流讨论。