ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

AI Agent问数项目基础设施搭建:从LangGraph到MCP的落地实践

2026/9/12 2:37:28 拓冰建站 浏览量
AI Agent问数项目基础设施搭建:从LangGraph到MCP的落地实践 去年我们团队在推进LCODER系列里的问数项目时第一版Demo只用了不到两百行代码就把“查一下上周各渠道的转化率”这类问题跑通了。当时在场的人都觉得这事快成了追着问什么时候能上生产。结果一接真实业务库问题全冒出来了几十张表摆在模型面前字段注释缺了一大半同一个指标在营销口径和财务口径下定义完全不一样SQL生成基本靠运气多轮追问更是接不住。复盘下来问题不在模型选型也不在Prompt调优而是整个项目缺了一层真正靠谱的基础设施。这篇是LCODER问数项目智能体搭建系列的第二篇主题是基础设施搭建。我会把从技术选型、项目骨架、数据库连接层、Agent运行时到可观测性的完整落地过程记录下来包括每一步的选型理由、关键配置和实际踩过的坑。如果你正准备做AI Agent方向的数据问答产品或者已经跑通了Demo但不知道怎么推向生产环境这篇文章应该能帮你把弯路的成本降下来。1. 为什么问数项目要先把基础设施当一等公民1.1 “能跑Demo”和“能上线”之间的真实差距很多人觉得Agent开发的核心是把LLM接进来、把Prompt写好、把工具调用打通剩下的事情都是工程小事。这个认知在简单Demo阶段没问题因为Demo里有几个前提表只有三五张、问题都是预设好的、答错了重问一次就行、完全不用考虑成本和权限。一旦进入真实业务这些前提全部失效。真实场景下的问数项目面对的是几十张甚至上百张关联表字段命名可能是cust_id、user_id、member_id混着来同一个“用户”在不同表里根本不是同一个字段。模型需要准确理解表结构、字段含义、枚举值甚至还要知道“月活跃用户”和“活跃用户数”是不是同一个口径。这些信息不会自动出现在模型的脑子里它必须通过一个工程化的元数据层被精准地投喂给LLM。我第一次在生产环境遇到的问题是模型生成的SQL把两张大表做了笛卡尔积直接把业务库的CPU打满了。那一刻我才意识到问数Agent根本不缺“智能”它缺的是一层能把数据访问、权限控制、SQL安全、上下文管理这些事兜住的底座。没有这层底座模型的每一次输出都像是在悬崖边上走路你不知道哪一步会掉下去。1.2 问数项目基础设施的四个核心模块我把问数项目的基础设施拆成四个模块这四块分别在后续章节里详细展开模块核心职责典型问题项目骨架与配置依赖、密钥、多环境管理版本不一致、密钥泄露数据库连接层Schema同步、连接池、安全边界SQL质量差、查询打爆业务库Agent运行时LLM接入、工具注册、状态管理工具散落、多轮上下文丢失可观测性日志、Trace、成本监控出了问题无法回溯成本失控这四个模块不是做完就结束的它们在后续功能迭代里会持续被复用。比如后面要接新的数据源、增加新的分析工具、或者从单Agent演进到multi-agent架构都是在这层地基上去扩展。地基没打好后面每走一步都在还债。1.3 为什么这一篇要单独拿出来讲系列的第一篇已经梳理了问数项目的整体技术方案按道理可以直接进入业务逻辑开发。但我们在实际推进中发现如果先把Agent的业务代码全部写完再回头补基础设施几乎等于重写一遍。因为Agent的业务代码和基础设施是深度耦合的工具的注册方式、状态的存储结构、节点的组织方式都取决于底层怎么搭。所以我决定把基础设施这一块单独拆出来做成完整的一篇。这样既方便团队内部按章节对照落地也方便读者把这篇当作一个独立的“搭地基手册”来用。接下来进入正题。2. 技术选型LangGraph做主流程编排MCP做工具标准化2.1 为什么没有选Spring AI技术选型是基础设施搭建的第一步也是讨论最久的一步。我们团队内部原本分两派一派倾向Java技术栈的Spring AI认为团队Java背景强、能快速上手另一派主张用Python生态的LangGraph理由是问数场景的数据处理链路天然适合Python。先客观说下Spring AI的情况。Spring AI发展到今天已经具备了完整的多Agent编排能力Spring AI Multi-Agent的官方文档和案例都挺全如果团队全是Java工程师、系统也跑在Spring体系内那它确实是一个合理选择。它和Java生态的监控、配置、部署体系能无缝衔接企业级落地成本低。但我们最终选LangGraph主要基于三个现实考量。第一问数项目的数据分析链路很长后面大概率要接pandas、polars这类Python数据工具做结果聚合和图表渲染Python生态在这块几乎无可替代第二LangGraph的图编排模型对“意图识别 → SQL生成 → SQL校验 → 执行 → 结果解释”这种带分支和循环的流程非常契合Spring AI的编排模型偏线性做条件跳转和循环没那么直观第三团队里已经有Data方向的Python工程师Agent层用Python不会产生额外沟通成本。2.2 LangGraph的状态、节点、条件边如何对应问数流程LangGraph的核心抽象是State、Node和Edge这套抽象和问数流程几乎是天生一对的。State是全局的状态容器用来在节点之间传递数据Node是每个处理单元可以是一个函数也可以是一段子图Edge定义了节点之间的流转关系条件边支持根据当前State的内容动态决定下一步走哪条路。我把问数主流程设计成了一条带分支的图用户提问进入intent_recognition节点判断是不是数据类问题、涉及哪些表然后进入schema_retrieval节点从元数据层拉取相关表结构接着到sql_generator节点生成SQL生成后走到schema_validator节点做语法和字段校验。校验失败就走条件边回到sql_generator重写最多重试两次校验通过后如果需要人工确认就走human_approve节点挂起否则直接执行查询最后进入结果解释。这个图真正解决了传统Chain流程解决不了的两个问题一是失败重试不再是简单的“报错重来”而是可以带着错误信息回到特定节点做定向修复二是人工确认被做成了图里的一个节点整个流程可以在这个节点暂停、等待、再恢复这在问数场景里非常重要因为直接执行SQL这件事必须有闸门。2.3 MCP协议在基础设施层的位置MCP就是Model Context Protocol放在Agent基础设施这套语境里它解决的其实是一个很朴素的工程问题Agent怎么发现工具、怎么调用工具、工具怎么返回结果。以前的做法是给LLM硬编码若干个function定义传参格式全靠手工对齐换一个模型供应商又得重新适配。问数项目里工具类型其实不少列举数据表、获取表结构、执行只读SQL、查询指标口径、获取图表配置……如果每个工具都直接在Agent代码里写死后面新增数据源或调整工具逻辑时就得改Agent主流程。我用MCP把工具层单独抽出来做了一个标准化的访问层Agent端只需要知道MCP Server暴露了哪些Tool按标准协议调用就行工具内部怎么实现Agent不用关心。目前在MCP协议下我们把数据库查询、元数据获取这类能力封装成了标准Tool后续接入更多数据源或者把工具开放给其他业务系统使用都只需要扩展MCP Server不需要动Agent本体。这也是我把MCP划入基础设施层的原因——它管的是“工具与Agent之间的关系”属于整个系统长期演进的地基部分。3. 项目骨架与配置从环境到目录的一次性到位3.1 pyenv poetry版本和依赖的治理从第一天开始做过Python项目的都懂版本和依赖管理如果不在一开始理顺后面每次“在我机器上是好的”都会让人抓狂。问数项目对Python版本有硬性要求因为LangGraph和langchain-openai这些库的更新节奏很快不同版本之间API差异非常大锁不住版本就意味着锁不住行为。我们用pyenv管理Python版本项目固定使用3.11.9避免不同开发机之间因为Python版本产生隐蔽的兼容问题。依赖管理用poetry核心价值在于poetry.lock文件会把所有传递依赖的精确版本锁死团队任何人拉代码后执行poetry install得到的依赖树完全一致。pyenv install 3.11.9 pyenv local 3.11.9 poetry init poetry add langgraph langchain langchain-openai pydantic-settings sqlalchemy pymysql redis fastapi uvicorn有个小忠告pyproject.toml里的依赖声明尽量用宽松的上限约束比如langgraph: ^0.2.0但一定要提交poetry.lock。这样平时开发用的是锁定版本未来升级时可以通过更新lock文件来控制变更范围而不是让依赖在不知不觉中乱跳。3.2 配置模型不再让密钥散落各处很多项目会把数据库连接串、API Key直接写在代码里或者随手填在某个.py文件的全局变量里。这种做法在Demo阶段没什么但一旦多人协作、多环境部署就会变成灾难本机连的是开发库联调环境连的是测试库生产环境的密钥还可能被误提交到Git仓库。我们用pydantic-settings统一管理配置所有的配置项都定义成带有类型和默认值的Settings模型环境变量通过env_prefix自动映射。这样配置项就是代码的一部分写错了IDE会提示、类型错误会在启动时直接报出来而不是运行时莫名其妙的连不上数据库。from pydantic_settings import BaseSettings, SettingsConfigDict class DBSettings(BaseSettings): host: str localhost port: int 3306 user: str ask_agent password: str database: str ask_agent pool_size: int 5 max_overflow: int 5 pool_recycle: int 3600 pool_pre_ping: bool True model_config SettingsConfigDict(env_prefixDB_, env_file.env) class LLMSettings(BaseSettings): provider: str openai model: str qwen-plus api_key: str base_url: str temperature: float 0.0 max_retries: int 2 request_timeout: float 60.0 model_config SettingsConfigDict(env_prefixLLM_, env_file.env)密钥管理上本地开发用.env文件但.env必须加入.gitignore仓库里只提交.env.example作为模板。生产环境的密钥放到云厂商的密钥管理服务里进程启动时从密封存储读取注入环境变量。这个习惯要从项目第一天就养成后面改起来成本很高。3.3 目录结构按Agent生命周期划分子模块项目目录结构是基础设施的一部分它决定了后续功能迭代时代码会被放到哪里、新成员能不能快速看懂系统。我的划分原则是“按Agent生命周期”而不是按传统的MVC分层。ask_agent/ ├── app/ │ ├── api/ # FastAPI 路由层 │ ├── core/ # 配置、常量、工具函数 │ ├── database/ # 连接池、元数据同步、只读执行器 │ ├── agent/ # LangGraph图定义与节点逻辑 │ ├── tools/ # MCP工具定义与注册表 │ ├── schemas/ # API请求/响应模型 │ └── services/ # 业务服务会话、权限、成本统计 ├── tests/ ├── pyproject.toml ├── poetry.lock ├── .env.example └── Dockerfiletools/目录和agent/目录的分隔是最重要的一条线Agent只依赖工具注册表对外暴露的接口工具的具体实现细节对Agent完全透明。后续加新工具只需要在tools/下新增文件并注册Agent主流程完全不用动。3.4 Docker Compose一条命令拉起全部依赖问数项目的基础依赖是MySQL和Redis。MySQL存业务数据Redis缓存会话状态和Schema元数据快照。为了让每个开发者的本地环境保持一致我们用Docker Compose把这两个服务编排起来一条命令拉到同一套版本和配置。services: mysql: image: mysql:8.0 container_name: ask-mysql ports: - 3306:3306 environment: MYSQL_ROOT_PASSWORD: root MYSQL_DATABASE: ask_agent command: - --character-set-serverutf8mb4 - --lower_case_table_names1 redis: image: redis:7 container_name: ask-redis ports: - 6379:6379这里有两个配置值得注意。character-set-serverutf8mb4保证中文字段注释和查询结果不会乱码lower_case_table_names1让MySQL表名大小写不敏感避免开发环境大小写规则和生产不一致导致的SQL兼容问题。这两个问题都是我们真实踩过的如果没有提前配置后面排查起来非常浪费时间。4. 数据库连接层问数质量的隐形天花板4.1 连接池参数问数场景不能照搬Web应用数据库连接池是数据访问层的第一道关。很多从Web后端转过来的同学会直接照搬常见的连接池配置——pool_size20、max_overflow20这在普通CRUD场景没问题但用在问数Agent上就是灾难。问数场景的特点是单个查询重、并发不高、每个查询可能要扫很多数据。如果连接池开得太大Agent在应对并发请求时可能同时对业务库发起十几个重查询直接把数据库连接数和IO打满。我把连接池参数设计成偏保守的配置DB_POOL_SIZE5 DB_MAX_OVERFLOW5 DB_POOL_RECYCLE3600 DB_POOL_PRE_PINGTruepool_pre_pingTrue一定要开MySQL默认的空闲连接超时是8小时超过这个时间连接会被服务端断开客户端如果不做探活直接使用会导致Lost connection错误。pool_recycle3600是让连接池在1小时内主动回收连接双保险应对断连问题。4.2 Schema元数据同步比调Prompt更管用的优化问数Agent的SQL质量天花板不在模型能力而在于它能不能拿到准确的表结构信息。模型再强如果不知道orders表里status字段有哪几个枚举值它就可能在SQL里写出status completed而实际值是finish。我们的方案是做一个Schema元数据同步模块定时从information_schema抽取表、字段、类型、注释、索引信息全量落成一份JSON存在Redis里Agent在生成SQL前通过get_table_schema工具拉取当前问题相关的表结构。实际同步用的SQL类似这样SELECT t.TABLE_NAME, c.COLUMN_NAME, c.DATA_TYPE, c.COLUMN_COMMENT, c.IS_NULLABLE, c.COLUMN_KEY, c.EXTRA FROM information_schema.TABLES t LEFT JOIN information_schema.COLUMNS c ON t.TABLE_NAME c.TABLE_NAME AND t.TABLE_SCHEMA c.TABLE_SCHEMA WHERE t.TABLE_SCHEMA business_db AND t.TABLE_TYPE BASE TABLE ORDER BY t.TABLE_NAME, c.ORDINAL_POSITION;比表结构更重要的是一份“业务口径字典”。我们单独维护了一张metric_definition表把“GMV”“新客”“月活跃用户”这些指标的SQL定义、业务解释、适用范围都记录下来。Agent在生成SQL前会先检索口径字典确保同一个指标在任何场景下都用同一套定义。这块工作做扎实之后SQL生成的正确率提升非常明显比反复调Prompt有效得多。4.3 安全边界只读账号、语句校验与敏感列脱敏问数Agent直接操作数据库安全边界怎么强调都不过分。我们做了三层防护。第一层是数据库账号权限。Agent使用的MySQL账号只授予SELECT权限从数据库层面杜绝任何写操作。这个账号专门为问数项目创建绝不使用业务系统的高权限账号CREATE USER ask_agent% IDENTIFIED BY strong-password; GRANT SELECT ON business_db.* TO ask_agent%; FLUSH PRIVILEGES;第二层是SQL语句的规则校验。在SQL真正执行前会经过一个检查器多重拦截不允许出现INSERT、UPDATE、DELETE、DROP、ALTER等关键字不允许出现分号堆叠的多语句不允许无WHERE条件的大表全表扫描。如果触发拦截会把具体原因返回给Agent让它改写SQL。第三层是敏感列脱敏。在Schema元数据里标记哪些字段属于敏感信息比如手机号、身份证号。Agent生成的SQL如果SELECT了这些列结果集返回前会做打码处理。这样即使Agent误打误撞查了敏感数据也不会完整的泄露给前端展示。这三层缺一不可。账号权限管的是数据库层语句校验管的是Agent行为层脱敏管的是数据展示层每一层都有绕过另外两层的可能性。5. Agent运行时状态管理、工具注册与执行路径5.1 LLM接入层一套配置兼容多家模型供应商问数项目不能绑死一家模型供应商原因很现实模型迭代太快今天的领先者可能三个月后就被超越而且不同模型在不同任务上的表现差异很大有的擅长SQL生成、有的擅长结果解释。如果代码里硬编码了某个供应商的SDK换模型就等于改代码。我们用langchain-openai的ChatOpenAI做统一接入层因为这个库兼容所有支持OpenAI协议的服务包括国内的大模型平台。通过配置base_url就能在多家供应商之间切换本地开发还能切到兼容OpenAI协议的本地模型做调试。from langchain_openai import ChatOpenAI llm ChatOpenAI( modelsettings.llm.model, api_keysettings.llm.api_key, base_urlsettings.llm.base_url, temperaturesettings.llm.temperature, max_retriessettings.llm.max_retries, request_timeoutsettings.llm.request_timeout, )这套设计的额外好处是可以用同一种方式做故障转移。比如主模型超时后捕获异常切换备用的base_url和model整个过程对Agent上层是透明的。问数项目依赖LLM的程度很高容灾能力不是加分项是必须项。5.2 工具注册表让工具可发现、可复用问数项目的工具类型很多如果每个工具都在业务代码里被直接引用后面的维护成本会指数上升。我把工具统一放在app/tools/目录下通过一个注册表做集中管理# app/tools/registry.py _registry: dict[str, Tool] {} def register_tool(tool: Tool): _registry[tool.name] tool return tool def get_tool(name: str) - Tool: return _registry[name] def list_tools() - list[Tool]: return list(_registry.values())每个工具定义为一个Tool对象包含名称、描述、参数Schema、执行函数、权限级别。其中描述字段特别重要因为它是LLM判断“什么场景该调用这个工具”的依据描述写得模糊模型就会在错误的场景调错工具。register_tool list_tables_tool Tool( namelist_tables, description列出当前数据源中所有可查询的表和视图名称在开始回答数据类问题前建议先调用此工具了解范围, parameters{ type: object, properties: { keyword: {type: string, description: 按表名关键词过滤可选} } }, handlerlist_tables, permissionread, )目前问数项目已经注册了四类核心工具list_tables列出所有表、get_table_schema获取表结构、execute_sql只读执行查询SQL、get_metric_definition查询指标口径。做MCP封装的时候这些工具会被自动透出给Agent进行发现和调用。5.3 State设计与多轮会话隔离LangGraph的State是整个图流转的数据中枢它的结构设计直接影响后续每个节点能拿到什么、能写什么。问数项目对State有两个核心要求一是能存储中间产物二是能支持多轮会话隔离。我用TypedDict定义的State结构如下from typing import TypedDict, Optional class AgentState(TypedDict): session_id: str messages: list[dict] intent: Optional[str] current_sql: Optional[str] query_result: Optional[str] error: Optional[str] sql_retry_count: intsession_id是隔离维度同一个用户的不同会话、不同用户的相同会话在Redis里都按session_id分key存储。current_sql保存上一次生成的SQL方便多轮追问时基于上一轮的上下文做修正。sql_retry_count用于控制SQL重写的最大次数避免模型陷入无限修复的死循环。多轮会话的保存策略是每个节点执行完就把当前State序列化到RedisTTL设置为24小时。这样即使用户中途关了页面重新打开时还能接着上一轮的上下文继续提问。5.4 条件边、人工确认与multi-agent扩展位LangGraph的条件边是问数流程里最灵活的部分。SQL校验失败的修复路径、敏感操作的审批路径都是通过条件边实现的。人工确认是我特别想强调的一个节点。human_approve节点做的事情很简单把待执行的SQL和影响范围展示给前端用户用interrupt机制挂起整个图的执行等用户点击“确认执行”后通过Command恢复图的状态继续往下走。这个机制在问数项目里是安全性的兜底尤其当查询涉及敏感表或查询范围较大的时候。关于multi-agent的演进我在设计图结构的时候特意留了扩展位。当前是单Agent跑完整条链路但每个Node的职责已经按角色拆得很清晰意图识别、SQL生成、SQL校验、执行、结果解释每一个节点其实都可以在未来替换成独立的子Agent。等业务复杂度上来了要做“调度Agent 取数Agent 图表Agent”的multi-agent架构只需要在图中插入新节点不需要推翻重来。6. 可观测性Agent不能是黑盒6.1 结构化日志先让链路可回溯Agent系统最让人头痛的问题就是调试。传统的Web接口有明确的请求和响应出了问题看日志就能定位但Agent在一次回答里可能经历了多次LLM调用、多次工具调用还可能有循环和分支。如果日志没有结构化的关联字段你根本不知道用户在哪个环节开始出错的。我们统一用JSON格式输出日志保证每个日志行都包含trace_id、session_id、node_name、tool_name、latency_ms、token_usage这些字段。trace_id在请求入口生成贯穿整个Agent执行链路。这样在日志平台里按trace_id一搜整个执行过程就串起来了。{ts: 2025-01-18T10:12:33.221Z, trace_id: 8f2a1c..., session_id: s-202, node: sql_generator, tool: get_table_schema, latency_ms: 842, token_usage: {prompt: 3210, completion: 128}, status: ok}这里有个经验不要只在出错的时候打日志成功路径上的关键节点也要打。因为很多Agent问题不是“报错”而是“答得不对”。没有成功路径上的trace你根本不知道模型是带着什么样的上下文做出的错误判断。6.2 用Langfuse看每一次思考过程结构化日志解决的是“链路可回溯”但要真正理解Agent为什么这么回答还需要看到LLM的输入输出细节。我们用了Langfuse做Trace追踪它是开源的、可以自部署和LangChain/LangGraph集成做得很好。Langfuse能记录的东西比普通日志细得多每次LLM调用的完整Prompt和Completion、工具调用的参数和返回结果、节点之间的流转关系和耗时、Token消耗。配置方式很简单设置环境变量后通过Callback自动上报。LANGFUSE_PUBLIC_KEY你的公钥 LANGFUSE_SECRET_KEY你的私钥 LANGFUSE_HOSThttp://localhost:3000有一个场景最能体现Langfuse的价值用户反馈说“查询结果不对”普通日志只能看到执行了某条SQL但Langfuse能告诉你模型是从哪一段表结构描述里推断出该用这个字段的。如果发现模型理解错了字段含义你改的就不是SQL逻辑而是Schema元数据里的字段描述。这种定位精度没有Trace工具根本做不到。6.3 成本与性能问数最怕预算失控Agent系统的成本问题比传统接口严峻得多。一个传统接口的数据库查询成本是可控的但Agent一次回答可能调用了两三次大模型接口每次都可能消耗几千个Token。如果没有成本监控一个重度用户的提问量就可能把月度预算跑穿。我们在基础设施层做了两层成本控制。第一层是单次请求的Token用量统计每次LLM调用结束后把prompt_tokens和completion_tokens记录到结构化日志第二层是按session_id和用户维度的成本聚合实时计算每个会话的累计消耗。cost prompt_tokens * price_in completion_tokens * price_out一个典型的问数链路大概会有两次LLM调用SQL生成一次、结果解释一次。生成SQL的那次调用因为要附带大量表结构上下文Token消耗往往是大头。优化方向很明确减少投喂给模型的冗余Schema信息。比如用户问的是订单相关的问题就没必要把用户表的全部字段都塞进去按“表级摘要 相关字段详情”的方式裁剪上下文能省下不少成本。6.4 错误兜底别把异常直接抛给用户Agent在真实使用中一定会出错关键是出错之后系统怎么表现。如果模型生成的SQL执行失败直接把数据库报错堆栈抛给用户这显然不行。我们在基础设施层预设了一套错误兜底机制。LLM超时重试一次仍然超时就返回固定文案“生成查询超时请重试或换一种问法”同时记录一条warning日志。SQL执行失败把数据库报错信息作为上下文回传给模型让模型根据错误提示自行修复SQL最多重试2次超过次数后返回“暂时无法完成查询请换个问法”。权限拒绝和敏感数据命中返回统一的安全提示绝不让模型自由发挥。这三类兜底策略的共同点是都通过“图的循环边”来实现而不是在节点代码里硬编码catch。这样的好处是每次修复尝试都会在图里留下完整的trace记录你可以在Langfuse里看到模型是如何根据错误信息调整SQL的。我在这套基础设施上跑完第一个月后的整体感受是后续往Agent里加新工具、接新数据源已经不太需要动主流程代码改动范围都收敛在各自的目录里。如果你也准备搭问数项目我建议把Schema元数据同步和可观测性这两个环节优先搞定这两个最容易被当成“以后再说”的事但往往是后面返工最痛苦的环节。地基这东西塌了再修比一开始就做好的代价大太多。