从零构建 Multi-Agent 系统:SQLAgent + RAGAgent + 智能路由实战

一、项目背景

单一的健身 Agent 对于不同需求的问题会出现不同的问题。

比如用户问"我上周跑了多少公里?"------这需要查数据库,返回精确数字。

但用户又问"跑步后腿酸怎么办?"------这需要检索专业文档,返回知识性建议。

如果强行用一个 Agent 处理所有问题,要么数据查询不准,要么知识回答缺乏依据。

这就是 Multi-Agent 系统的价值所在:让不同的 Agent 各司其职,由一个协调层统一调度。

最终实现的效果如下:

用户用自然语言提问,系统自动判断问题类型并路由:

  • 数据类问题 → SQLAgent 查询 SQLite 数据库,返回精确结果

  • 知识类问题 → RAGAgent 检索专业文档,返回有依据的建议

  • 综合类问题 → 两者协同,数据 + 知识合并成完整回答

技术栈一览
组件 选型
LLM 千问 qwen-turbo(OpenAI兼容接口)
Embedding 千问 text-embedding-v3(1024维)
精排模型 千问 gte-rerank
向量数据库 ChromaDB(本地持久化)
结构化数据库 SQLite
界面框架 Streamlit
本地调试 Ollama + qwen3:4b

二、系统架构设计

整体架构

先看整体架构,再逐层拆解:

整个系统分三层:Agent 层、协调层、适配层,每层职责清晰,互不耦合。


设计决策一:模板方法模式(BaseAgent)

所有 Agent 都继承自 BaseAgent 抽象基类:

python 复制代码
class BaseAgent(ABC):
    def __init__(self):
        self.logger     = logging.getLogger(self.__class__.__name__)
        self.agent_name = self.__class__.__name__

    def run(self, request: AgentRequest) -> AgentResponse:
        """公开入口,所有子类统一走这里"""
        self._validate(request)      # 验证输入
        start = time.time()
        try:
            response = self._process(request)  # 子类实现业务逻辑
        except Exception as e:
            return self._handle_error(e, request)
        finally:
            elapsed = int((time.time() - start) * 1000)
            self.logger.info(f"{self.agent_name} 耗时 {elapsed}ms")
        return response

    @abstractmethod
    def _process(self, request: AgentRequest) -> AgentResponse:
        """子类必须实现,只写业务逻辑"""
        ...

这样设计的好处是:验证、计时、日志、错误处理由基类统一处理,子类只写业务逻辑。 新增一个 Agent 只需要继承 BaseAgent 并实现 _process(),其他全部复用。


设计决策二:适配器模式(LLM 统一接口)

项目开发阶段用本地 Ollama 调试(零费用),上线切换千问云端 API,业务代码一行不改

python 复制代码
class BaseLLM:
    def invoke(self, prompt: str) -> str:
        """统一接口,所有子类必须实现"""
        response = self.client.chat.completions.create(
            model       = self.model,
            messages    = [{"role": "user", "content": prompt}],
            temperature = self.temperature,
        )
        return response.choices[0].message.content

class OllamaLLM(BaseLLM):
    def __init__(self, model="qwen3:4b"):
        self.client = OpenAI(
            base_url = "http://localhost:11434/v1",
            api_key  = "ollama",
        )

class QwenLLM(BaseLLM):
    def __init__(self, model="qwen-turbo"):
        self.client = OpenAI(
            base_url = "https://dashscope.aliyuncs.com/compatible-mode/v1",
            api_key  = os.getenv("DASHSCOPE_API_KEY"),
        )

千问和 Ollama 都实现了 OpenAI 兼容接口,切换只需要改一行初始化代码。


设计决策三:依赖注入

所有组件从外部注入依赖,不在内部创建:

python 复制代码
# ✅ 依赖注入:可测试,可替换
coordinator = Coordinator(
    llm       = QwenLLM(),     # 外部传入
    sql_agent = SQLAgent(...),
    rag_agent = RAGAgent(...),
)

# ❌ 硬编码:难测试,无法替换
class Coordinator:
    def __init__(self):
        self.llm = QwenLLM()  # 内部创建,测试时无法换成Mock

正是因为这个设计,测试时才能方便地注入 Mock 对象,30 个测试用例得以快速运行。


四种路由策略

Coordinator 支持四种调度模式:

路由类型 触发场景 执行方式
SQL_ONLY 纯数据查询 只调用 SQLAgent
RAG_ONLY 纯知识问答 只调用 RAGAgent
SEQUENTIAL 需要数据支撑知识回答 先SQL后RAG,SQL结果传入RAG的context
PARALLEL 数据和知识互相独立 两者同时调用,结果合并

三、SQLAgent:自然语言查数据库

SQLAgent 负责把用户的自然语言问题转换成 SQL 并执行,返回结构化数据。内部由两个模块组成:SQLGenerator (生成SQL)和 SQLExecutor(执行SQL)。


整体流程
plain 复制代码
用户问题
    │
    ▼
SQLGenerator
    ├── 填充 prompt(问题 + 表结构 + 用户ID + 当前日期)
    ├── 调用 LLM 生成 SQL
    ├── 安全检查(三层防御)
    └── 返回 SQLGenerationResult
    │
    ▼
SQLExecutor
    ├── 执行 SQL(SQLite)
    ├── 结果转成 List[Dict] 格式
    └── 返回 SQLExecutionResult
    │
    ▼
AgentResponse(content = JSON字符串)

Prompt 设计

Prompt 是 SQLAgent 的核心,设计得好坏直接决定生成 SQL 的质量。

最终版 prompt 包含四个关键要素:

python 复制代码
self._prompt_template = """
你是一个专业的数据库专家,将自然语言转换为准确的 SQL 查询。

# 数据库结构
{table_schema}

# 当前上下文
- {user_id_instruction}
- 当前日期:{current_date}
- 时间定义:
  · "今天" = date('now')
  · "最近7天" = date('now', '-7 days') 至今

# 查询示例
示例1:查询特定用户的跑步距离
SQL: SELECT COALESCE(SUM(distance), 0) FROM workout_sessions 
     WHERE user_id=1 AND sport_type=1

示例2:查询所有用户总数(全局统计)
SQL: SELECT COUNT(*) FROM users

# 输出要求
1. 只返回一行 SQL,不要任何解释
2. 不要加分号
3. 只允许 SELECT 查询
4. 只在涉及特定用户时才加 WHERE user_id 条件

SQL:
"""
易错点

这里有一个关键踩坑 :最初 prompt 里写的是"必须包含 WHERE user_id 条件",导致查询用户总数这种全局统计也被加上了 WHERE user_id=unknown,SQL 直接报错。

解决方案是根据是否传入有效 user_id,动态生成不同的约束

python 复制代码
if user_id and user_id != "unknown":
    user_id_instruction = f"当前用户ID:{user_id},查询个人数据时用 WHERE user_id={user_id} 过滤"
else:
    user_id_instruction = "这是全局统计查询,不需要 WHERE user_id 条件"

三层 SQL 安全防御

即使 LLM 生成了危险 SQL,也无法对数据库造成破坏

第一层:关键词黑名单

python 复制代码
DANGEROUS_KEYWORDS = ["UPDATE", "DELETE", "INSERT", "DROP", "ALTER", "TRUNCATE"]

def _safety_check(self, sql: str) -> None:
    sql_upper = sql.upper()  # 转大写,防止大小写绕过
    for keyword in DANGEROUS_KEYWORDS:
        if keyword in sql_upper:
            raise SQLGenerationError(f"包含禁止关键词: {keyword}")

第二层:sqlparse 语句类型检查

python 复制代码
def _parse_check(self, sql: str) -> None:
    parsed     = sqlparse.parse(sql)[0]
    first_token = parsed.get_type()
    if first_token != "SELECT":
        raise SQLGenerationError("只允许SELECT查询")

第三层:数据库只读权限

即使前两层被绕过(比如通过注释混淆),数据库本身配置为只读,写操作直接被拒绝。

三层防御的逻辑是:拦截意图 → 拦截语法 → 拦截执行,层层兜底。


空结果 vs 执行错误的区分

SQLExecutor 有一个容易忽略但很重要的设计:

python 复制代码
@dataclass
class SQLExecutionResult:
    success:    bool
    rows_count: int
    data:       Optional[List[Dict]]  # [] 表示空结果,None 表示执行失败
    error:      Optional[str]

data=[]data=None 是两种完全不同的状态:

plain 复制代码
data = []    → 查询成功,数据库里没有符合条件的记录
              → 告诉用户:"您还没有跑步记录,快去运动吧!"

data = None  → SQL 执行失败,系统出现异常
              → 告诉用户:"系统繁忙,请稍后重试"
              → 同时触发告警,通知运维介入

两者混淆会把系统故障当成"没有数据"处理,掩盖真实问题。


另一个踩坑:session_id 和 user_id 混用

开发过程中遇到过一个隐蔽的 bug:

plain 复制代码
LLM 生成的 SQL:
WHERE user_id=20d71de2-509a-44e9-995a-e1802047eb9e

这是 UUID 格式,数据库里 user_id 是整数
→ SQL 语法错误,系统崩溃

根本原因是把 AgentRequest.session_id(UUID,用于追踪请求)当成了数据库的 user_id(整数,用于过滤数据)传给了 LLM。

修复方案是在 AgentRequest 里单独加一个 user_id 字段:

python 复制代码
@dataclass
class AgentRequest:
    task:       str
    session_id: str = field(default_factory=lambda: str(uuid.uuid4()))
    user_id:    Optional[str] = None   # 数据库层面的用户ID,和session_id完全不同

这个 bug 的教训 :概念相似的字段一定要明确区分,不能混用。session_id 标识"这次请求",user_id 标识"这个人",两者生命周期和用途完全不同。


四、RAGAgent:两阶段文档检索

RAGAgent 负责从专业文档库里找到最相关的内容,结合 LLM 生成有依据的回答。核心设计是两阶段检索:向量召回 + 精排。


为什么需要两阶段?

很多教程直接用向量检索就结束了,但实际效果并不理想。原因在于:

向量检索只看"语义相似",不理解"问题意图"。

比如用户问"跑步后腿酸怎么办?",向量检索可能返回:

plain 复制代码
第1名:0.57  "跑步技术与损伤预防"(提到跑步,但没针对腿酸)
第2名:0.61  "常见跑步损伤处理指南"(直接回答腿酸)← 这个更有用
第3名:0.67  "力量训练对耐力的提升"(相关性一般)

Reranker 的作用是对候选结果重新精排,从"语义相似"升级为"对回答最有帮助":

plain 复制代码
精排后:
第1名  0.507  "常见跑步损伤处理指南" ← 被提升到第一
第2名  0.434  "跑步技术与损伤预防"
第3名  0.429  "力量训练对耐力的提升" ← 抗疲劳内容被识别为相关

但为什么不直接对全量文档做精排?

plain 复制代码
文档库有 32 个块,直接精排 = 32 次 API 调用 → 慢且贵

两阶段方案:
  第一阶段:向量检索毫秒级找出 Top-5 候选(数学计算,极快)
  第二阶段:Reranker 只处理 5 个块 → 快且准确

Retriever:向量召回
python 复制代码
class Retriever:
    def __init__(self, docs_dir: str, db_path: str):
        # 千问 Embedding 模型,1024维向量
        self.embed_client = OpenAI(
            api_key  = os.getenv("DASHSCOPE_API_KEY"),
            base_url = "https://dashscope.aliyuncs.com/compatible-mode/v1"
        )
        # ChromaDB 持久化存储,重启后不丢失
        self.chroma_client = chromadb.PersistentClient(path=db_path)
        self.collection    = self.chroma_client.get_or_create_collection("fitmind_docs")

    def _chunk_text(self, text: str, chunk_size=500, overlap=100) -> List[str]:
        """带重叠的固定大小切块,防止关键信息被切断"""
        chunks, start = [], 0
        while start < len(text):
            chunks.append(text[start:start + chunk_size])
            start += chunk_size - overlap  # 步长=500-100=400,保留100字重叠
        return chunks

    def load_documents(self) -> int:
        """离线阶段:文档切块→Embedding→存入ChromaDB"""
        if self.collection.count() > 0:
            return self.collection.count()  # 已加载则跳过,避免重复
        # ... 读取文件、切块、embedding、存储

为什么用 PersistentClient 而不是内存模式?

内存模式每次重启都要重新 Embedding 全部文档。32 个块还好,生产环境文档库可能有几万个块,每次重启重新 Embedding 要花几小时,还会产生大量 API 费用。持久化到磁盘,只需要第一次加载,后续直接检索。


Reranker:精排
python 复制代码
class Reranker:
    def rerank(self, retrieval_result: RetrievalResult) -> RerankResult:
        documents = [chunk.content for chunk in retrieval_result.chunks]

        resp = TextReRank.call(
            model     = "gte-rerank",
            query     = retrieval_result.query,
            documents = documents,
            top_n     = self.top_n,
        )

        reranked_chunks, reranked_scores = [], []
        for item in resp.output.results:
            reranked_chunks.append(retrieval_result.chunks[item.index])
            reranked_scores.append(item.relevance_score)

        return RerankResult(query=retrieval_result.query,
                           chunks=reranked_chunks, scores=reranked_scores)

注意 Reranker 返回的 relevance_score 越高越相关,和 Retriever 的 distance(越小越相关)方向相反。两者的本质区别:

plain 复制代码
distance(向量距离):数学计算,看语义相似度
relevance_score(相关性):模型理解,看能否回答问题

relevance_score 更能反映真实相关性,但计算成本更高
两阶段结合,兼顾效率和准确率

RAGAgent 组装
python 复制代码
def _process(self, request: AgentRequest) -> AgentResponse:
    # 第一阶段:向量召回
    retrieval_result = self.retriever.retrieve(query=request.task, top_k=5)

    if not retrieval_result.chunks:
        raise RetrievalEmptyError("未检索到相关文档")

    # 第二阶段:精排
    rerank_result = self.reranker.rerank(retrieval_result)

    # 拼接 context,带来源标注
    context = "\n\n".join([
        f"[来源:{chunk.source}]\n{chunk.content}"
        for chunk in rerank_result.chunks
    ])

    # LLM 生成回答
    answer = self.llm.invoke(self._answer_prompt.format(
        context = context,
        query   = request.task,
    ))

    # sources 去重
    sources = list(set([chunk.source for chunk in rerank_result.chunks]))

    return AgentResponse(status=AgentStatus.SUCCESS,
                        content=answer, sources=sources, ...)

五、Coordinator:智能调度层

Router:LLM 驱动的路由决策
python 复制代码
self._prompt_template = """
你是一个智能路由助手。根据用户问题判断调用哪个Agent。

可用Agent:
- SQLAgent:处理结构化数据查询、数字统计
- RAGAgent:处理文档检索、知识问答

路由规则:
- 只需要数据查询 → sql_only
- 只需要文档检索 → rag_only
- 需要数据+文档(后者依赖前者)→ sequential
- 需要数据+文档(互相独立)→ parallel

只输出JSON:
{{"route_type": "类型", "reasoning": "原因", "order": ["顺序"]}}
"""

Router 有两层容错机制:

python 复制代码
try:
    data = json.loads(raw_response)
    return RouteDecision(
        route_type = RouteType(data.get("route_type", "sequential")),
        reasoning  = data.get("reasoning", "无原因"),   # .get() 防止KeyError
        order      = data.get("order", ["sql", "rag"]),
    )
except json.JSONDecodeError:
    # LLM 返回非法 JSON,使用默认策略
    return RouteDecision(
        route_type = RouteType.SEQUENTIAL,
        reasoning  = "LLM返回格式异常,使用默认路由策略",
        order      = ["sql", "rag"],
    )

为什么用 .get() 而不是直接用 []

LLM 返回的 JSON 是外部不可控数据,可能缺少某个字段。.get("key", 默认值) 在 key 不存在时返回默认值,[] 索引则直接抛 KeyError。对外部数据始终用防御性编程。


SEQUENTIAL 的核心:上下文传递

SEQUENTIAL 最关键的逻辑是把 SQL 结果传给 RAG:

python 复制代码
def _run_sequential(self, request: AgentRequest) -> AgentResponse:
    # 第一步:SQL 查询数据
    sql_response = self.sql_agent.run(request)

    # 第二步:把 SQL 结果塞进 RAG 的 context
    rag_request = AgentRequest(
        task    = request.task,
        context = [{"role": "assistant", "content": sql_response.content}]
        # ↑ RAGAgent 带着这份数据去检索最相关的建议
    )

    # 第三步:RAG 基于数据给出建议
    rag_response = self.rag_agent.run(rag_request)

    # 第四步:LLM 合并两份结果
    return self._merge(sql_response, rag_response)

如果没有这步上下文传递,RAGAgent 不知道用户跑了多少公里,只能给出通用建议。有了数据支撑,回答才能真正个性化。


六、测试体系

测试金字塔

为什么大部分用 Mock?

python 复制代码
# 不用Mock:每次测试真实调用LLM
# 100个测试 × 3秒 = 5分钟,CI/CD无法接受
# 而且LLM输出不稳定,今天通过明天可能失败

# 用Mock:
mock_llm = MagicMock()
mock_llm.invoke.return_value = "SELECT COUNT(*) FROM users"
# 毫秒级,结果100%可预期,免费

Mock 测试的核心思想:测试的是"我的代码逻辑",不是"LLM的能力"。LLM 返回什么是 LLM 厂商的责任,我们只需要测试拿到返回值后,自己的代码处理是否正确。


一个有价值的测试:验证 SEQUENTIAL 的上下文传递
python 复制代码
def test_sequential_route():
    # ... 设置 Mock

    result = coordinator.run("分析我的跑步数据并给出训练建议")

    # ⭐ 核心断言:SQL结果是否真的传入了RAG的context
    rag_call_request = mock_rag_agent.run.call_args[0][0]
    assert SQL_CONTENT in rag_call_request.context[0]["content"]
    # 如果这个断言失败,说明 SEQUENTIAL 退化成了 PARALLEL
    # 两个Agent各自独立运行,没有数据传递,回答缺乏个性化

这类行为验证测试在 Agent 开发里特别重要。数据传递错误不会报异常,只会让回答质量下降,很难靠人工发现。


七、踩坑总结

开发过程中遇到的坑,记录下来希望对你有用。

坑1:load_dotenv() 位置不对

python 复制代码
# ❌ 放在 if __name__ == "__main__" 里
# 其他文件 import 这个模块时,__main__ 块不执行
# os.getenv("DASHSCOPE_API_KEY") 返回 None → 报错

# ✅ 放在模块顶层,import 之后立刻调用
from dotenv import load_dotenv
load_dotenv()   # 模块被导入时自动执行

坑2:LangChain 和 OpenAI SDK 返回格式不同

python 复制代码
# LangChain:返回 AIMessage 对象
raw = llm.invoke(prompt)
sql = raw.content.strip()   # 需要取 .content

# OpenAI SDK / Ollama:直接返回字符串
raw = llm.invoke(prompt)
sql = raw.strip()            # 直接用

# 踩坑:按 LangChain 写法写了代码,换成 OpenAI SDK 后全部报错
# AttributeError: 'str' object has no attribute 'content'
# 解决:用适配器模式统一接口,invoke() 永远返回 str

坑3:new 跳过 init 的风险

python 复制代码
# 测试时用 __new__ 跳过 __init__ 注入 Mock
agent = RAGAgent.__new__(RAGAgent)

# 风险:BaseAgent.__init__ 里初始化的属性全部缺失
# agent.agent_name → AttributeError
# agent.logger     → AttributeError

# 修复:手动补上父类初始化的属性
agent.logger     = logging.getLogger("RAGAgent")
agent.agent_name = "RAGAgent"

坑4:Prompt 约束导致的静默错误

python 复制代码
# ❌ prompt 里写死"必须包含 WHERE user_id 条件"
# 查询用户总数时生成:SELECT COUNT(*) FROM users WHERE user_id=unknown
# SQL报错,但如果不测试根本发现不了

# ✅ 根据是否有有效 user_id,动态调整约束
# 这类"静默错误"比报错更危险:数据悄悄出错,系统不报警

坑5:.gitignore 文件名拼错

plain 复制代码
.gitinore   ← 少了一个 t
# .gitignore 不生效,.env 差点被上传到 GitHub
# API Key 泄露后果很严重,一定要在第一次 git add 前确认

八、总结与后续规划

项目地址

GitHub:https://github.com/crgon/fitmind-multi-agent

本地运行:

bash 复制代码
pip install streamlit openai chromadb dashscope python-dotenv sqlparse
streamlit run app.py

这个项目让我理解的几件事

Multi-Agent 的价值不是"多个AI",而是"分工"。 每个 Agent 只做一件事,做好做精,比一个全能 Agent 效果好得多。

Prompt 工程和代码工程同等重要。 Prompt 写得不好,不会报错,只会让结果悄悄变差。需要像测试代码一样测试 Prompt。

分层测试是 Agent 开发的必要投入。 全链路失败时,能在30秒内定位到具体哪一层出了问题,这个能力值得花时间建立。


后续可以加的功能
plain 复制代码
□ 用户系统:支持注册登录,填写身高/体重/年龄等基本信息,存入数据库
□ 目标制定:用户说明减脂/增肌目标,助手生成每周运动量和每日饮食建议
□ 饮食运动记录:用户用自然语言描述今日饮食和运动,助手自动估算卡路里并记录,支持当日总结
□ 流式输出(Streaming):回答逐字显示,减少等待焦虑
□ 对话记忆:多轮对话时保留上下文
□ 异步并发:PARALLEL 模式升级为真正的 asyncio 并发

全文完。如果对你有帮助,欢迎点赞收藏,有问题在评论区交流

相关推荐
墨染天姬2 小时前
【AI】PyTorch/TF 也会变成考古?
人工智能·pytorch·python
郑同学zxc4 小时前
机器学习18-tensorflow3
人工智能·机器学习
这张生成的图像能检测吗4 小时前
(论文速读)基于快速局域谱滤波的卷积神经网络
人工智能·神经网络·cnn·图神经网络·分类模型
wuxuand4 小时前
2026论文阅读——BayesAHDD:当贝叶斯决策规则遇上小样本单类分类
论文阅读·人工智能·分类·数据挖掘
wuxuand4 小时前
2026论文阅读——FedOCC:当单类分类遇上联邦学习——生成对抗+联邦蒸馏的新范式
人工智能·分类·数据挖掘
小陳参上6 小时前
用Python创建一个Discord聊天机器人
jvm·数据库·python
minstbe8 小时前
IC设计私有化AI助手实战:基于Docker+OpenCode+Ollama的数字前端综合增强方案(进阶版)
人工智能·python·语言模型·llama
GinoInterpreter9 小时前
什么是翻译的去中心化?
人工智能·自然语言处理·去中心化·区块链·机器翻译·机器翻译模型·机器翻译引擎
zyq99101_110 小时前
优化二分查找:前缀和降复杂度
数据结构·python·蓝桥杯