企业实战:数据图与状态定义

目录

  • [1 定义状态 (State)](#1 定义状态 (State))
  • [2 定义主图](#2 定义主图)
    • [2.1 第一步:创建节点骨架](#2.1 第一步:创建节点骨架)
    • [2.2 第二步:编写主图代码](#2.2 第二步:编写主图代码)
    • [2.3 第三步:验证图流程](#2.3 第三步:验证图流程)

1 定义状态 (State)

所有节点共享同一个状态对象。我们需要定义它来存储处理过程中的数据(如 查询原始问题、改写问题、历史对话、不同方向切片结果等)。
#mermaid-svg-BT9gjR6QH0h5ajSx{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-BT9gjR6QH0h5ajSx .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-BT9gjR6QH0h5ajSx .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-BT9gjR6QH0h5ajSx .error-icon{fill:#552222;}#mermaid-svg-BT9gjR6QH0h5ajSx .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-BT9gjR6QH0h5ajSx .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-BT9gjR6QH0h5ajSx .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-BT9gjR6QH0h5ajSx .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-BT9gjR6QH0h5ajSx .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-BT9gjR6QH0h5ajSx .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-BT9gjR6QH0h5ajSx .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-BT9gjR6QH0h5ajSx .marker{fill:#333333;stroke:#333333;}#mermaid-svg-BT9gjR6QH0h5ajSx .marker.cross{stroke:#333333;}#mermaid-svg-BT9gjR6QH0h5ajSx svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-BT9gjR6QH0h5ajSx p{margin:0;}#mermaid-svg-BT9gjR6QH0h5ajSx .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-BT9gjR6QH0h5ajSx .cluster-label text{fill:#333;}#mermaid-svg-BT9gjR6QH0h5ajSx .cluster-label span{color:#333;}#mermaid-svg-BT9gjR6QH0h5ajSx .cluster-label span p{background-color:transparent;}#mermaid-svg-BT9gjR6QH0h5ajSx .label text,#mermaid-svg-BT9gjR6QH0h5ajSx span{fill:#333;color:#333;}#mermaid-svg-BT9gjR6QH0h5ajSx .node rect,#mermaid-svg-BT9gjR6QH0h5ajSx .node circle,#mermaid-svg-BT9gjR6QH0h5ajSx .node ellipse,#mermaid-svg-BT9gjR6QH0h5ajSx .node polygon,#mermaid-svg-BT9gjR6QH0h5ajSx .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-BT9gjR6QH0h5ajSx .rough-node .label text,#mermaid-svg-BT9gjR6QH0h5ajSx .node .label text,#mermaid-svg-BT9gjR6QH0h5ajSx .image-shape .label,#mermaid-svg-BT9gjR6QH0h5ajSx .icon-shape .label{text-anchor:middle;}#mermaid-svg-BT9gjR6QH0h5ajSx .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-BT9gjR6QH0h5ajSx .rough-node .label,#mermaid-svg-BT9gjR6QH0h5ajSx .node .label,#mermaid-svg-BT9gjR6QH0h5ajSx .image-shape .label,#mermaid-svg-BT9gjR6QH0h5ajSx .icon-shape .label{text-align:center;}#mermaid-svg-BT9gjR6QH0h5ajSx .node.clickable{cursor:pointer;}#mermaid-svg-BT9gjR6QH0h5ajSx .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-BT9gjR6QH0h5ajSx .arrowheadPath{fill:#333333;}#mermaid-svg-BT9gjR6QH0h5ajSx .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-BT9gjR6QH0h5ajSx .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-BT9gjR6QH0h5ajSx .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-BT9gjR6QH0h5ajSx .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-BT9gjR6QH0h5ajSx .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-BT9gjR6QH0h5ajSx .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-BT9gjR6QH0h5ajSx .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-BT9gjR6QH0h5ajSx .cluster text{fill:#333;}#mermaid-svg-BT9gjR6QH0h5ajSx .cluster span{color:#333;}#mermaid-svg-BT9gjR6QH0h5ajSx div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-BT9gjR6QH0h5ajSx .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-BT9gjR6QH0h5ajSx rect.text{fill:none;stroke-width:0;}#mermaid-svg-BT9gjR6QH0h5ajSx .icon-shape,#mermaid-svg-BT9gjR6QH0h5ajSx .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-BT9gjR6QH0h5ajSx .icon-shape p,#mermaid-svg-BT9gjR6QH0h5ajSx .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-BT9gjR6QH0h5ajSx .icon-shape .label rect,#mermaid-svg-BT9gjR6QH0h5ajSx .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-BT9gjR6QH0h5ajSx .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-BT9gjR6QH0h5ajSx .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-BT9gjR6QH0h5ajSx :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 用户查询入口
开始
1.意图识别与改写
2.多路召回
向量搜索
HyDE检索
网络搜索
3.结果融合与粗排
4.重排序
5.LLM生成答案
结束
SSE流式输出

文件 : app/query_process/agent/state.py

python 复制代码
from typing_extensions import TypedDict
from typing import List
import copy

class QueryGraphState(TypedDict):
    """
    QueryGraphState 定义了整个查询流程中流转的数据结构。
    """
    session_id: str  # 会话唯一标识
    original_query: str  # 用户原始问题

    # 检索过程中的中间数据
    embedding_chunks: list  # 普通向量检索回来的切片
    hyde_embedding_chunks: list  # HyDE 检索回来的切片
    web_search_docs: list  # 网络搜索回来的文档

    # 排序过程中的数据
    rrf_chunks: list  # RRF 融合排序后的切片
    reranked_docs: list  # 重排序后的最终 Top-K 文档

    # 生成过程中的数据
    prompt: str  # 组装好的 Prompt
    answer: str  # 最终生成的答案

    # 辅助信息
    item_names: List[str]  # 提取出的商品名称
    rewritten_query: str  # 改写后的问题
    history: list  # 历史对话记录
    is_stream: bool  # 是否流式输出标记


# ========================
# 默认状态(全部为空)
# ========================
query_graph_default_state: QueryGraphState = {
    "session_id": "",
    "original_query": "",
    "embedding_chunks": [],
    "hyde_embedding_chunks": [],
    "web_search_docs": [],
    "rrf_chunks": [],
    "reranked_docs": [],
    "prompt": "",
    "answer": "",
    "item_names": [],
    "rewritten_query": "",
    "history": [],
    "is_stream": False
}


# ========================
# 创建默认状态(可覆盖)
# ========================
def create_query_default_state(**overrides) -> QueryGraphState:
    """
    创建查询流程的默认状态,支持覆盖字段
    """
    state = copy.deepcopy(query_graph_default_state)
    state.update(overrides)
    return state


# ========================
# 获取干净状态
# ========================
def get_query_default_state() -> QueryGraphState:
    return copy.deepcopy(query_graph_default_state)


# ========================
# ✅ 状态复制函数(你要的)
# ========================
def copy_query_state(state: QueryGraphState, **overrides) -> QueryGraphState:
    """
    复制现有状态并可覆盖字段,深拷贝,不污染原数据
    """
    new_state = copy.deepcopy(state)
    new_state.update(overrides)
    return new_state


if __name__ == "__main__":
    # 测试
    state = create_query_default_state(
        session_id="test_001",
        original_query="华为P60怎么样?",
        is_stream=False
    )
    print("初始化状态:", state)

    # 复制状态
    new_state = copy_query_state(
        state,
        original_query="修改后的问题"
    )
    print("复制后的状态:", new_state)

2 定义主图

为了确保整体流程设计的科学性与执行连贯性,我们采用 "Top-Down"(自顶向下) 的开发模式,以 "总指挥部" 的全局视角统筹推进,具体实施步骤如下:

  1. 搭建节点骨架(Stubs):优先定义全流程所需的所有功能节点,仅保留核心日志打印能力(如节点进入 / 退出日志),暂不实现内部复杂业务逻辑,快速搭建起流程的 "骨架结构";
  2. 串联主图(Graph):基于预设的业务流转规则,编写主图逻辑将所有节点骨架按序串联,明确节点间的输入输出关系、分支判断条件(如文件格式分流逻辑),形成完整的流程链路;
  3. 验证流程通畅性:启动端到端测试,验证节点间的调用链路是否通顺、数据流转是否符合预期、分支跳转是否准确,确保流程无阻塞、无逻辑漏洞;
  4. 填充节点核心逻辑:在流程链路验证通过后,再逐一聚焦每个节点的内部实现,完成复杂业务逻辑的开发(如 查询重写、定向查询、结果重排处理等),实现 "骨架" 到 "完整系统" 的落地。

该模式的核心优势在于:先保障 "流程走得通",再聚焦 "功能做得好",避免因局部逻辑复杂导致整体流程设计偏差,大幅提升开发效率与流程稳定性。

2.1 第一步:创建节点骨架

我们需要先创建以下 8 个文件,每个文件里只写一个最简单的"空函数",确保主图能导入它们。

请在 app/query_process/agent/nodes 目录下创建以下文件,并将对应代码复制进去。

(1) 意图确认: node_item_name_confirm.py

这个节点主要干了 4 件事:

  1. 提取与改写 :结合历史对话提取商品名,并将模糊问题改写为完整独立的精准问题。
  2. 向量化检索 :将提取出的商品名在 Milvus 向量库中进行混合搜索。
  3. 标准化对齐 :根据评分高低自动对齐标准型号,或生成反问让用户手动确认。
  4. 同步历史记录 :将改写后的问题、确认的商品名和处理状态实时写入 MongoDB 数据库。
python 复制代码
import time
import sys

from app.utils.task_utils import add_running_task, add_done_task


def node_item_name_confirm(state):
    """
    节点功能:确认用户问题中的核心商品名称。
    输入:state['original_query']
    输出:更新 state['item_names']
    """
    print(f"---node_item_name_confirm---开始处理")
    # 记录任务开始
    add_running_task(state["session_id"], sys._getframe().f_code.co_name,state["is_stream"])

    # 后面会调用大模型,进行逻辑处理
    time.sleep(1)
    # 记录任务结束
    add_done_task(state["session_id"], sys._getframe().f_code.co_name,state["is_stream"])

    print(f"---node_item_name_confirm---处理结束")

    return {"item_names": ["示例商品"]}

(2) 向量检索: node_search_embedding.py

这个节点 node_search_embedding 负责根据 改写后的用户问题 ,在 限定的商品范围内 ,利用 BGEM3 混合检索(稠密+稀疏) 技术,从 Milvus 向量数据库中召回 Top5 最相关的知识切片。

python 复制代码
import time
import sys
from app.utils.task_utils import  add_done_task,add_running_task

def node_search_embedding(state):
    """
    节点功能:进行向量内容检索
    """
    print("---量内容检索 开始处理---")
    add_running_task(state["session_id"], sys._getframe().f_code.co_name, state.get("is_stream"))

    # 搜索假设性答案
    print("量内容检索答案!!")
    time.sleep(1)

    # ...
    add_done_task(state["session_id"], sys._getframe().f_code.co_name, state.get("is_stream"))

    print("---量内容检索 处理结束---")
    return state

(3) HyDE 假设检索节点: node_search_embedding_hyde.py

这个节点 node_search_embedding_hyde 实现了 HyDE (Hypothetical Document Embeddings) 策略,核心逻辑是 先让 LLM 虚构一个"理想答案",再用这个答案去向量库检索真实的文档 。

一句话总结: 它通过"LLM 生成假设性答案"来增强原始问题的语义信息,再进行混合向量检索,从而大幅提升对"语义匹配但字面不匹配"问题的召回能力。

python 复制代码
import time
import sys
from app.utils.task_utils import  add_done_task,add_running_task

def node_search_embedding_hyde(state):
    """
    节点功能:HyDE (Hypothetical Document Embedding)
    先让 LLM 生成假设性答案,再对答案进行向量检索,提高召回率。
    """
    print("---HyDE 开始处理---")
    add_running_task(state["session_id"], sys._getframe().f_code.co_name, state.get("is_stream"))

    # 搜索假设性答案
    print("搜索架设性答案!!")
    time.sleep(1)

    # ...
    add_done_task(state["session_id"], sys._getframe().f_code.co_name, state.get("is_stream"))

    print("---HyDE 处理结束---")
    return state

(4) 网络搜索节点: node_web_search_mcp.py

这个节点 node_web_search_mcp 负责调用 百炼 MCP (Model Context Protocol) 联网搜索服务 ,获取互联网上的实时信息。

一句话总结: 它通过 MCP 协议异步调用百炼联网搜索接口,将用户的查询转化为实时的、结构化的网络搜索结果(包含标题、链接和摘要)。

python 复制代码
import time
import sys
from app.utils.task_utils import add_done_task,add_running_task

def node_web_search_mcp(state):
    """
    节点功能,调用外部搜索引擎补充信息
    :param state:
    :return:
    """
    add_running_task(state["session_id"], sys._getframe().f_code.co_name,state["is_stream"])
    print("---node-web-search-mcp处理---")

    add_done_task(state["session_id"],sys._getframe().f_code.co_name,state["is_stream"])
    time.sleep(1)
    # 调用mcp外部引擎
    print(f"调用外部mcp引擎")

    print("---node-web-search-mcp处理结束---")
    return state

(5) 倒排融合排序节点: node_rrf.py

这个节点 node_rrf 负责 将来自不同检索源(如向量检索、HyDE 检索等)的文档列表进行统一合并和重新排序 。它消除了不同检索方式的分数差异,给出一个综合排名最高的文档列表(Top 10)。

什么是 RRF (Reciprocal Rank Fusion, 倒排排序融合)?

RRF 是一种 不需要知道具体分数,只关心排名 的融合算法。

通俗解释: 假设你在参加一场全能比赛:

  • 裁判 A(向量检索) 觉得你是第 1 名。
  • 裁判 B(HyDE 检索) 觉得你是第 3 名。
  • 裁判 C(关键字检索) 觉得你是第 10 名。

RRF 不管裁判打具体多少分(因为不同裁判打分标准不同,有的满分100,有的满分10),它只看 排名 。

计算公式:

  • rank 是你在某个裁判那里的排名(第1名就是1,第2名就是2)。
  • k 是一个常数(通常取 60),用来防止排名靠前的差距过大。

RRF 的核心思想:

  • 奖励多面手 :如果你在多个榜单上都排在前面,你的总分就会很高。
  • 平滑差异 :即使你在某个榜单上稍微落后,只要其他榜单表现好,总分依然能上去。
  • 去重 :同一个文档在不同榜单出现多次,RRF 会把它合并,只算一次总分。

举个栗子:假设我们有两个检索来源:

  1. 来源 A (向量检索) 找出了前 3 名:

    • 第 1 名: 文档 X
    • 第 2 名: 文档 Y
    • 第 3 名: 文档 Z
  2. 来源 B (HyDE 检索) 找出了前 3 名:

    • 第 1 名: 文档 Y (注意:它觉得 Y 比 X 好)
    • 第 2 名: 文档 Z
    • 第 3 名: 文档 W (新面孔)

我们来算一下每个文档的总分:

  1. 文档 X
  • 在 A 中排第 1 → 得分 = 1 / (60 + 1) = 0.0164
  • 在 B 中没出现 → 得分 = 0
  • 总分 = 0.0164 + 0 = 0.0164
  1. 文档 Y (重点看这个!)
  • 在 A 中排第 2 → 得分 = 1 / (60 + 2) = 0.0161
  • 在 B 中排第 1 → 得分 = 1 / (60 + 1) = 0.0164
  • 总分 = 0.0161 + 0.0164 = 0.0325
  1. 文档 Z
  • 在 A 中排第 3 → 得分 = 1 / (60 + 3) = 0.0159
  • 在 B 中排第 2 → 得分 = 1 / (60 + 2) = 0.0161
  • 总分 = 0.0159 + 0.0161 = 0.0320
  1. 文档 W
  • 在 A 中没出现 → 得分 = 0
  • 在 B 中排第 3 → 得分 = 1 / (60 + 3) = 0.0159
  • 总分 = 0 + 0.0159 = 0.0159

最终排名结果:

  1. 第一名:文档 Y (0.0325) (虽然在 A 排第二,但在 B 排第一,综合实力最强)
  2. 第二名:文档 Z (0.0320) (两个榜单都上榜,表现稳定)
  3. 第三名:文档 X (0.0164) (偏科,只在 A 表现好)
  4. 第四名:文档 W (0.0159)

总结: RRF 就是通过这种方式,让 "在多个榜单都靠前" 的文档排到最前面,比单看某一个榜单更靠谱!

python 复制代码
import time
import sys
from app.utils.task_utils import add_running_task, add_done_task

def node_rrf(state):
    """
    节点功能:Reciprocal Rank Fusion
    将多路召回的结果(向量、HyDE、Web、KG)进行加权融合排序。
    """
    print("---RRF---")
    add_running_task(state["session_id"], sys._getframe().f_code.co_name, state.get("is_stream"))
    time.sleep(1)
    # ...
    add_done_task(state['session_id'], sys._getframe().f_code.co_name, state.get("is_stream"))
    return state

(6) 重排序节点: node_rerank.py

这个节点 node_rerank 是知识库检索的"精修师",它对之前所有步骤召回的文档进行 二次精细排序 。

核心流程分为三步:

  1. 合并文档 :将来自 RRF(本地检索)和 Web Search(联网搜索)的文档合并到一个池子中。
  2. 精确打分 :使用重排序模型计算每个文档与用户问题的相关性得分。
  3. 动态截断 :根据得分的"断崖式下跌"点,智能截取 TopK(最多 10 条),只保留高质量结果,过滤凑数的低分文档。

该节点使用的是 BGE Reranker (BAAI/bge-reranker-large) 模型。

  • 模型类型 :Cross-Encoder(交叉编码器)。
  • 工作原理 :它不像向量检索那样分别计算向量再比对,而是直接把"问题"和"文档"拼接在一起扔给模型,让模型像阅读理解一样,深入分析两者的语义匹配度。
  • 特点 :
    • 精度极高 :能识别微小的语义差异(如"苹果手机"和"苹果公司"的区别)。
    • 速度较慢 :因为计算量大,所以通常只用于对初筛后的少量文档(如 Top 20-50)进行精排,不适合全库检索。
python 复制代码
import time
import sys
from app.utils.task_utils import add_running_task, add_done_task

def node_rerank(state):
    """
    节点功能:使用 Cross-Encoder 模型对 RRF 后的结果进行精确打分重排。
    """
    print("---Rerank处理---")
    add_running_task(state["session_id"], sys._getframe().f_code.co_name, state.get("is_stream"))

    time.sleep(1)
    # ...
    add_done_task(state['session_id'], sys._getframe().f_code.co_name, state.get("is_stream"))
    return state

(7) 答案生成: node_answer_output.py

这个节点 node_answer_output 是知识库查询的"最后一公里",负责 生成最终回答 并 交付给用户 。

它整合了之前所有步骤的成果,通过以下 5 个核心动作完成任务:

  1. 检查前置答案 :如果之前步骤(如商品名确认节点)已经生成了追问或拒绝回答,直接输出,跳过 LLM 生成。
  2. 构建 Prompt :将用户问题、历史对话、以及 Rerank 后的 TopK 高质量文档片段(包含元数据)组装成一段严谨的提示词。
  3. LLM 生成与流式推送 :调用大模型生成最终答案。如果是流式模式,会**逐字推送(Delta)**给前端,实现打字机效果。
  4. 图片提取与增强 :从引用文档中自动提取图片 URL(包括网页链接和本地 Markdown 图片),为纯文本答案补充视觉信息。
  5. 收尾与存档 :将最终答案和提取的图片写入 MongoDB 历史记录,并向前端发送包含完整信息(答案+图片)的 FINAL 信号 。
python 复制代码
import time
import sys
from app.core.logger import logger
from app.utils.sse_utils import push_to_session, SSEEvent
from app.utils.task_utils import add_running_task, add_done_task

def node_answer_output(state):
    """
    节点功能:进行过处理可以是流式输出可以整体输出!
    """
    print("---node_answer_output 节点处理开始---")
    add_running_task(state["session_id"], sys._getframe().f_code.co_name, state.get("is_stream"))

    session_id = state["session_id"]
    is_stream = state.get("is_stream", True)
    base_answer = state.get("answer") or f"这是关于「{state.get('original_query', '当前问题')}」的测试回答,正在演示打字机流式输出效果。"
    final_text = ""

    if is_stream:
        for ch in base_answer:
            final_text += ch
            push_to_session(session_id, SSEEvent.DELTA, {"delta": ch})
            time.sleep(0.03)

        image_urls = ["https://example.com/demo-1.png", "https://example.com/demo-2.png"]
        push_to_session(
            session_id,
            SSEEvent.FINAL,
            {
                "answer": final_text,
                "status": "completed",
                "image_urls": image_urls
            }
        )
        logger.info(f"流式输出完成,总长度: {len(final_text)}")
    else:
        final_text = base_answer

    add_done_task(state['session_id'], sys._getframe().f_code.co_name, state.get("is_stream"))
    print("---node_answer_output 节点处理结束---")
    return {"answer": final_text}

2.2 第二步:编写主图代码

#mermaid-svg-BT9gjR6QH0h5ajSx{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-BT9gjR6QH0h5ajSx .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-BT9gjR6QH0h5ajSx .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-BT9gjR6QH0h5ajSx .error-icon{fill:#552222;}#mermaid-svg-BT9gjR6QH0h5ajSx .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-BT9gjR6QH0h5ajSx .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-BT9gjR6QH0h5ajSx .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-BT9gjR6QH0h5ajSx .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-BT9gjR6QH0h5ajSx .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-BT9gjR6QH0h5ajSx .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-BT9gjR6QH0h5ajSx .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-BT9gjR6QH0h5ajSx .marker{fill:#333333;stroke:#333333;}#mermaid-svg-BT9gjR6QH0h5ajSx .marker.cross{stroke:#333333;}#mermaid-svg-BT9gjR6QH0h5ajSx svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-BT9gjR6QH0h5ajSx p{margin:0;}#mermaid-svg-BT9gjR6QH0h5ajSx .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-BT9gjR6QH0h5ajSx .cluster-label text{fill:#333;}#mermaid-svg-BT9gjR6QH0h5ajSx .cluster-label span{color:#333;}#mermaid-svg-BT9gjR6QH0h5ajSx .cluster-label span p{background-color:transparent;}#mermaid-svg-BT9gjR6QH0h5ajSx .label text,#mermaid-svg-BT9gjR6QH0h5ajSx span{fill:#333;color:#333;}#mermaid-svg-BT9gjR6QH0h5ajSx .node rect,#mermaid-svg-BT9gjR6QH0h5ajSx .node circle,#mermaid-svg-BT9gjR6QH0h5ajSx .node ellipse,#mermaid-svg-BT9gjR6QH0h5ajSx .node polygon,#mermaid-svg-BT9gjR6QH0h5ajSx .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-BT9gjR6QH0h5ajSx .rough-node .label text,#mermaid-svg-BT9gjR6QH0h5ajSx .node .label text,#mermaid-svg-BT9gjR6QH0h5ajSx .image-shape .label,#mermaid-svg-BT9gjR6QH0h5ajSx .icon-shape .label{text-anchor:middle;}#mermaid-svg-BT9gjR6QH0h5ajSx .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-BT9gjR6QH0h5ajSx .rough-node .label,#mermaid-svg-BT9gjR6QH0h5ajSx .node .label,#mermaid-svg-BT9gjR6QH0h5ajSx .image-shape .label,#mermaid-svg-BT9gjR6QH0h5ajSx .icon-shape .label{text-align:center;}#mermaid-svg-BT9gjR6QH0h5ajSx .node.clickable{cursor:pointer;}#mermaid-svg-BT9gjR6QH0h5ajSx .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-BT9gjR6QH0h5ajSx .arrowheadPath{fill:#333333;}#mermaid-svg-BT9gjR6QH0h5ajSx .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-BT9gjR6QH0h5ajSx .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-BT9gjR6QH0h5ajSx .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-BT9gjR6QH0h5ajSx .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-BT9gjR6QH0h5ajSx .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-BT9gjR6QH0h5ajSx .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-BT9gjR6QH0h5ajSx .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-BT9gjR6QH0h5ajSx .cluster text{fill:#333;}#mermaid-svg-BT9gjR6QH0h5ajSx .cluster span{color:#333;}#mermaid-svg-BT9gjR6QH0h5ajSx div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-BT9gjR6QH0h5ajSx .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-BT9gjR6QH0h5ajSx rect.text{fill:none;stroke-width:0;}#mermaid-svg-BT9gjR6QH0h5ajSx .icon-shape,#mermaid-svg-BT9gjR6QH0h5ajSx .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-BT9gjR6QH0h5ajSx .icon-shape p,#mermaid-svg-BT9gjR6QH0h5ajSx .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-BT9gjR6QH0h5ajSx .icon-shape .label rect,#mermaid-svg-BT9gjR6QH0h5ajSx .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-BT9gjR6QH0h5ajSx .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-BT9gjR6QH0h5ajSx .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-BT9gjR6QH0h5ajSx :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 用户查询入口
开始
1.意图识别与改写
2.多路召回
向量搜索
HyDE检索
网络搜索
3.结果融合与粗排
4.重排序
5.LLM生成答案
结束
SSE流式输出

文件 : app/query_process/agent/main_graph.py

1)初始化图与节点注册

python 复制代码
from langgraph.graph import StateGraph, END

from app.query_process.agent.nodes.node_answer_output import node_answer_output
from app.query_process.agent.nodes.node_item_name_confirm import node_item_name_confirm
from app.query_process.agent.nodes.node_rerank import node_rerank
from app.query_process.agent.nodes.node_rrf import node_rrf
from app.query_process.agent.nodes.node_search_embedding import node_search_embedding
from app.query_process.agent.nodes.node_search_embedding_hyde import node_search_embedding_hyde
from app.query_process.agent.nodes.node_web_search_mcp import node_web_search_mcp
from app.query_process.agent.state import QueryGraphState

builder = StateGraph(QueryGraphState)

# 注册节点(已删除虚拟节点)
builder.add_node("node_item_name_confirm", node_item_name_confirm)
builder.add_node("node_search_embedding", node_search_embedding)
builder.add_node("node_search_embedding_hyde", node_search_embedding_hyde)
builder.add_node("node_web_search_mcp", node_web_search_mcp)
builder.add_node("node_rrf", node_rrf)
builder.add_node("node_rerank", node_rerank)
builder.add_node("node_answer_output", node_answer_output)

# 入口
builder.set_entry_point("node_item_name_confirm")

# 条件路由
def route_after_item_confirm(state: QueryGraphState):
    if state.get("answer"):
        """
        这主要发生在 node_item_name_confirm 节点无法直接确定唯一的商品型号,从而需要"反问用户"或"拒绝回答"的场景。
        具体来说,有以下两种情况会导致 state 中直接出现 answer ,从而跳过后续的检索流程,直接输出:
        1. 多选一(反问用户) :
        - 场景 :用户问得太模糊(比如"华为P60"),系统发现数据库里有"华为P60 128G"和"华为P60 Art"两个型号,且置信度都不足以直接确认。
        - 处理 :节点会生成一条反问句作为 answer ,例如:"您是想问以下哪个产品:华为P60 128G、华为P60 Art?请明确一下型号。"
        - 结果 :此时不需要再去检索文档了,直接把这句话发给用户让他选。
        2. 查无此人(拒绝回答) :

        - 场景 :用户问了一个系统里压根没有的商品(比如"小米15",但库里只有华为的数据),或者评分过低(<0.6)。
        - 处理 :节点会生成一条拒绝句作为 answer ,例如:"抱歉,未找到相关产品,请提供准确型号以便我为您查询。"
        - 结果 :同样不需要后续检索,直接结束流程。
        """
        return "node_answer_output"
    # 直接进入并发搜索
    return "node_search_embedding", "node_search_embedding_hyde", "node_web_search_mcp"

builder.add_conditional_edges(
    "node_item_name_confirm",
    route_after_item_confirm,
    # 显示添加否则!不完整
{
        "node_answer_output": "node_answer_output",
        "node_search_embedding": "node_search_embedding",
        "node_search_embedding_hyde": "node_search_embedding_hyde",
        "node_web_search_mcp": "node_web_search_mcp",
    }
)

# 三个搜索 → 直接汇合到 RRF
builder.add_edge("node_search_embedding", "node_rrf")
builder.add_edge("node_search_embedding_hyde", "node_rrf")
builder.add_edge("node_web_search_mcp", "node_rrf")

# 正常流程
builder.add_edge("node_rrf", "node_rerank")
builder.add_edge("node_rerank", "node_answer_output")
builder.add_edge("node_answer_output", END)

query_app = builder.compile()

2.3 第三步:验证图流程

在实现具体业务逻辑前,我们先跑一个测试脚本,看看图能不能跑通,路线对不对。

创建测试文件 : test_query_main_graph.py (在项目根目录)

python 复制代码
import json

from app.query_process.agent.main_graph import query_app
from app.query_process.agent.state import create_query_default_state
from app.core.logger import logger

logger.info("===== 开始测试 =====")

initial_state = create_query_default_state(session_id="test_001",
        original_query="华为P60怎么样?")
final_state = None

# 只输出更最终的状态值(字典形式),不包含节点名称、执行日志、元数据等额外信息
for event in query_app.stream(initial_state):
    for key, value in event.items():
        logger.info(f"节点: {key}")
        final_state = value

# 格式化输出最终状态
logger.info(f"最终状态: {json.dumps(final_state, indent=4, ensure_ascii=False)}")

logger.info("图结构:")
# uv add grandalf
query_app.get_graph().print_ascii()

logger.info("===== 测试结束 =====")

如果能看到这些日志,说明我们的图结构搭建成功!接下来就可以放心地去填充每个节点的具体代码了。

相关推荐
青山是哪个青山2 小时前
LangChain 学习笔记(九):上下文与记忆
笔记·学习·langchain
一只叫煤球的猫5 小时前
Spring AI 2.0 源码解析(二):Starter 如何自动装配 ChatClient ?
后端·面试·langchain
GISer_Jing7 小时前
全栈AI实战:基于 TypeScript + LangChain + MCP 的企业级智能研发助手
前端·后端·ai·langchain·前端框架
红鼻子时代7 小时前
从 @tool 到四层架构:一个查天气 Agent 的拆解
langchain·agent·软件架构
承渊政道7 小时前
【Python编程—从入门到实践】(Python列表:从索引到增删排序的系统理解)
开发语言·python·pycharm·索引·列表·增删查改
a-6268 小时前
三维重建的步骤-1(采集的是环绕物体均匀旋转一圈的视频)
python·pycharm·三维重建·metashape
AI大佬的小弟11 小时前
大模型名词精讲14:Multi-Agent(多智能体协作)
多智能体·multiagent·langgraph·ai协作·ai入门·a2a·大模型基础概念
circuitsosk1 天前
AI输出的“质检员”:构建智能体质量评估、异常检测与人工兜底的三层防线
人工智能·python·microsoft·正则表达式·langchain
Albert Edison1 天前
PyCharm 字体大小设置
ide·python·pycharm