LangGraph + MCP 实战:构建"自然语言查数据"的 ChatBI Agent
前面几篇我们分别做了客服、多智能体协作、代码审查 Agent。这次升级到企业级场景:用 LangGraph 编排流程 + MCP 对接数据库,做一个用户说人话、Agent 自动查库返回结果的 ChatBI Agent。 一、整体架构 文本
编辑
用户提问 → LangGraph 意图识别 → 获取表结构 → 生成 SQL → 校验 SQL → 执行查询 → 结果解读 → 输出回答 关键分工: LangGraph:负责工作流编排,定义"谁在什么时候做什么",管理状态流转和异常回退。 MCP:负责把数据库操作封装成标准工具,Agent 通过统一协议调用,不需要为每个数据库写一套适配。 二、第一步:安装依赖 bash
编辑
js
pip install langgraph langchain-openai langchain-mcp-adapters fastmcp pymysql sqlalchemy
三、第二步:构建 MCP Database Server
MCP Server 是 Agent 和数据库之间的"薄代理层",本身不写业务逻辑,只负责协议转换和安全校验。
python
编辑
js
# mcp_db_server.py
import os
import json
from fastmcp import FastMCP
import pymysql
from contextlib import contextmanager
mcp = FastMCP("DatabaseServer")
DB_CONFIG = {
"host": os.getenv("DB_HOST", "localhost"),
"user": os.getenv("DB_USER", "root"),
"password": os.getenv("DB_PASSWORD", ""),
"database": os.getenv("DB_NAME", "sales"),
"charset": "utf8mb4",
}
@contextmanager
def get_db():
conn = pymysql.connect(**DB_CONFIG)
try:
yield conn
finally:
conn.close()
@mcp.tool()
def list_tables() -> str:
"""列出数据库中所有可用的表名"""
with get_db() as conn:
cursor = conn.cursor()
cursor.execute("SHOW TABLES")
tables = [row[0] for row in cursor.fetchall()]
return json.dumps(tables)
@mcp.tool()
def get_table_schema(table_name: str) -> str:
"""获取指定表的字段结构(列名、类型、注释)"""
with get_db() as conn:
cursor = conn.cursor()
cursor.execute(f"DESCRIBE `{table_name}`")
columns = cursor.fetchall()
schema = []
for col in columns:
schema.append({
"field": col[0],
"type": col[1],
"nullable": col[2],
"key": col[3],
"comment": col[5] if len(col) > 5 else ""
})
return json.dumps(schema, ensure_ascii=False)
@mcp.tool()
def execute_sql(sql: str) -> str:
"""执行只读 SQL 查询并返回结果。仅允许 SELECT 语句。"""
sql_upper = sql.strip().upper()
if not sql_upper.startswith("SELECT"):
return json.dumps({"error": "只允许 SELECT 查询,拒绝执行"})
if any(kw in sql_upper for kw in ["DROP", "DELETE", "UPDATE", "INSERT", "ALTER"]):
return json.dumps({"error": "检测到危险操作,拒绝执行"})
try:
with get_db() as conn:
cursor = conn.cursor(pymysql.cursors.DictCursor)
cursor.execute(sql)
rows = cursor.fetchall()
return json.dumps(rows, default=str, ensure_ascii=False)
except Exception as e:
return json.dumps({"error": str(e)})
if __name__ == "__main__":
mcp.run()
启动服务:
bash
编辑
python mcp_db_server.py
四、第三步:定义 State
python
编辑
from typing import TypedDict, Optional, List
from langchain_core.messages import HumanMessage, AIMessage
class ChatBIState(TypedDict):
messages: list[HumanMessage | AIMessage]
user_query: str # 用户原始问题
tables: Optional[str] # 可用表列表
schema: Optional[str] # 相关表结构
generated_sql: Optional[str] # 生成的 SQL
sql_valid: bool # SQL 是否通过校验
query_result: Optional[str] # 查询结果
final_answer: Optional[str] # 最终回答
error: Optional[str] # 错误信息
retry_count: int # 重试次数
五、第四步:连接 MCP 工具
使用 langchain-mcp-adapters 把 MCP Server 暴露的工具转换为 LangChain 标准 Tool 对象。
python
编辑
from langchain_mcp_adapters.client import MultiServerMCPClient
from langchain_openai import ChatOpenAI
llm = ChatOpenAI(model="gpt-4o", temperature=0.1)
async def get_mcp_tools():
client = MultiServerMCPClient({
"database": {
"command": "python",
"args": ["mcp_db_server.py"],
"transport": "stdio"
}
})
tools = await client.get_tools()
return tools
六、第五步:编写核心 Node
. Schema 发现节点:先搞清楚有哪些表、哪些字段
python
编辑
async def schema_discovery_node(state: ChatBIState) -> dict:
"""获取可用表和关键字段信息,为 SQL 生成提供上下文"""
tools = await get_mcp_tools()
list_tables_tool = next(t for t in tools if t.name == "list_tables")
tables_str = await list_tables_tool.ainvoke({})
tables = json.loads(tables_str)
# 根据用户问题,智能选择可能相关的表(简单版:取前3个表的结构)
# 生产环境可用向量检索做语义匹配
schema_info = []
get_schema_tool = next(t for t in tools if t.name == "get_table_schema")
for table in tables[:3]:
schema_str = await get_schema_tool.ainvoke({"table_name": table})
schema_info.append(f"表名: {table}\n{schema_str}\n")
return {
"tables": tables_str,
"schema": "\n".join(schema_info)
}
. SQL 生成节点:让 LLM 结合表结构生成 SQL
python
编辑
def sql_generation_node(state: ChatBIState) -> dict:
"""基于用户问题和表结构生成 SQL"""
query = state["user_query"]
schema = state["schema"]
prompt = f"""你是一个 SQL 专家。请根据以下数据库结构和用户问题,生成一条可执行的 MySQL 查询语句。
数据库表结构:
{schema}
用户问题:{query}
要求:
. 只使用上述表结构中的表和字段
. 只生成 SELECT 语句
. 结果默认 LIMIT 10,除非用户明确要求更多
. 只输出 SQL,不要任何解释
生成的 SQL:"""
response = llm.invoke(prompt)
sql = response.content.strip().strip("```sql").strip("```").strip()
return {"generated_sql": sql}
. SQL 校验节点:双重保险
python
编辑
def sql_validation_node(state: ChatBIState) -> dict:
"""校验 SQL 的安全性(只读)和语法合理性"""
sql = state["generated_sql"]
sql_upper = sql.upper()
# 基础安全检查
dangerous_keywords = ["DROP", "DELETE", "UPDATE", "INSERT", "ALTER", "CREATE", "TRUNCATE"]
for kw in dangerous_keywords:
if kw in sql_upper:
return {
"sql_valid": False,
"error": f"SQL 包含危险操作: {kw}"
}
if not sql_upper.startswith("SELECT"):
return {"sql_valid": False, "error": "只允许 SELECT 查询"}
# 让 LLM 做一次语义校验
schema = state["schema"]
check_prompt = f"""请校验以下 SQL 是否使用了正确的表和字段。
表结构:{schema}
SQL:{sql}
如果正确,只输出:VALID
如果有问题,输出:INVALID + 原因"""
response = llm.invoke(check_prompt)
if "INVALID" in response.content:
return {
"sql_valid": False,
"error": f"语义校验失败: {response.content}"
}
return {"sql_valid": True}
. SQL 执行节点:通过 MCP 工具查数据库
python
编辑
async def sql_execution_node(state: ChatBIState) -> dict:
"""通过 MCP 工具执行 SQL 查询"""
tools = await get_mcp_tools()
execute_tool = next(t for t in tools if t.name == "execute_sql")
result_str = await execute_tool.ainvoke({"sql": state["generated_sql"]})
result = json.loads(result_str)
if isinstance(result, dict) and "error" in result:
return {
"query_result": None,
"error": result["error"]
}
return {
"query_result": json.dumps(result, ensure_ascii=False, indent=2)
}
. 结果解读节点:把数据翻译成自然语言
python
编辑
def answer_generation_node(state: ChatBIState) -> dict:
"""将查询结果转化为自然语言回答"""
query = state["user_query"]
result = state["query_result"]
prompt = f"""你是一个数据分析师。用户问:{query}
查询结果如下:
{result}
请用简洁的中文回答用户,突出关键数据。如果结果为空,如实告知。"""
response = llm.invoke(prompt)
return {"final_answer": response.content}
. SQL 修正节点:校验或执行失败时重新生成
python
编辑
def sql_correction_node(state: ChatBIState) -> dict:
"""根据错误信息重新生成 SQL"""
query = state["user_query"]
schema = state["schema"]
error = state.get("error", "")
old_sql = state.get("generated_sql", "")
prompt = f"""之前的 SQL 执行失败,请修正。
用户问题:{query}
表结构:{schema}
原 SQL:{old_sql}
错误信息:{error}
请生成修正后的 SELECT 语句,只输出 SQL。"""
response = llm.invoke(prompt)
sql = response.content.strip().strip("```sql").strip("```").strip()
return {
"generated_sql": sql,
"retry_count": state.get("retry_count", 0) + 1
}
七、第六步:组装图并编译
python
编辑
from langgraph.graph import StateGraph, START, END
from typing import Literal
def route_after_validation(state: ChatBIState) -> Literal["execute_sql", "correct_sql"]:
if state["sql_valid"]:
return "execute_sql"
return "correct_sql"
def route_after_execution(state: ChatBIState) -> Literal["answer", "correct_sql", "__end__"]:
if state.get("error"):
if state.get("retry_count", 0) < 3:
return "correct_sql"
return "__end__" # 超过重试次数,输出错误
return "answer"
workflow = StateGraph(ChatBIState)
workflow.add_node("discover_schema", schema_discovery_node)
workflow.add_node("generate_sql", sql_generation_node)
workflow.add_node("validate_sql", sql_validation_node)
workflow.add_node("execute_sql", sql_execution_node)
workflow.add_node("correct_sql", sql_correction_node)
workflow.add_node("generate_answer", answer_generation_node)
# 主流程
workflow.add_edge(START, "discover_schema")
workflow.add_edge("discover_schema", "generate_sql")
workflow.add_edge("generate_sql", "validate_sql")
# 校验后分流
workflow.add_conditional_edges("validate_sql", route_after_validation)
# 执行后分流
workflow.add_conditional_edges("execute_sql", route_after_execution)
# 修正后重新校验
workflow.add_edge("correct_sql", "validate_sql")
# 生成答案后结束
workflow.add_edge("generate_answer", END)
app = workflow.compile()
八、第七步:运行测试
python
编辑
import asyncio
async def main():
result = await app.ainvoke({
"messages": [],
"user_query": "统计每个产品类别的销售额,按销售额降序排列",
"retry_count": 0
})
print("用户问题:", result["user_query"])
print("生成 SQL:", result.get("generated_sql"))
print("查询结果:", result.get("query_result"))
print("最终回答:", result.get("final_answer"))
if result.get("error"):
print("错误:", result["error"])
asyncio.run(main())
九、进阶:多 MCP Server 协同
生产环境中,Agent 往往需要同时访问多个系统。LangGraph MCP Adapters 支持同时连接多个 MCP Server:
python
编辑
async def get_multi_tools():
client = MultiServerMCPClient({
"database": {
"command": "python",
"args": ["mcp_db_server.py"],
"transport": "stdio"
},
"github": {
"command": "npx",
"args": ["-y", "@modelcontextprotocol/server-github"],
"transport": "stdio"
},
"filesystem": {
"command": "npx",
"args": ["-y", "@modelcontextprotocol/server-filesystem", "/data"],
"transport": "stdio"
}
})
return await client.get_tools()
这样 Agent 就能在查数据库的同时读取文件、搜索代码仓库,根据用户问题自主选择合适的工具链。 十、生产环境建议 SQL 安全是底线:MCP Server 层必须做只读校验,数据库账号用最小权限(只读用户),不要给 Agent 写权限。 Schema 发现要智能:表多时不能全量加载,可用向量检索(Qdrant/ES)做语义匹配,只召回相关表的结构。 给循环加退出条件:SQL 生成→校验→执行→修正的循环必须限制最大重试次数,避免死循环。 接入 LangSmith 做 Trace:记录每次 SQL 生成、校验、执行的全过程,方便排查"为什么生成了错的 SQL"。 HTTP 部署 MCP Server:生产环境用 streamable-http 传输协议,把 MCP Server 部署为独立微服务,方便统一鉴权和限流。 总结 这套架构的核心思想是:LangGraph 管流程,MCP 管工具。LangGraph 用状态机把"发现表结构 → 生成 SQL → 校验 → 执行 → 解读"串成可控的流水线;MCP 把数据库操作标准化,Agent 通过统一协议调用工具,换数据库、换表结构都不需要改 Agent 代码。相比直接让 LLM 裸写 SQL,多了 schema 发现和双重校验两道保险,准确率和安全性都更可控。