这几年 AI Agent 从概念到落地,最不缺的就是 PPT 和 demo。真正把 Agent 推到生产环境的时候,最先遇到的往往不是模型能力不够,而是基础设施缺位:密钥散落在代码里、日志查不到一次工具调用链路、数据库查询把线上库拖慢了、模型供应商一换整个流程重写。我在 LCODER 团队做“问数项目”时,第一阶段把业务需求梳理清楚,第二阶段就老老实实打地基。这篇就讲我们是怎么搭建这套 AI Agent 基础设施的,适合正在做或者准备做数据问答智能体的开发者,也适合想系统学习 Agent 工程化落地的人参考。
“问数项目”做什么?简单说,就是让业务人员用自然语言查询数据。用户输入“上个月华东区销售额环比是多少”,Agent 理解问题、生成 SQL、执行查询、返回一段人能看懂的结论。这个链路看着不长,但它横跨模型调用、任务编排、数据库访问、日志监控、权限控制好几个层面。任何一个环节没想清楚,上线后都会变成半夜打电话叫你起来的故障。基础设施搭建阶段,就是把这一条链路上的“地基”一件一件摆正。
1. 开工前先想清楚:问数项目的基础设施到底要解决什么问题
1.1 问数项目的本质是一条数据链路
问数项目本质上是把“自然语言到 SQL,再到结果解释”这条链路自动化。用户的一句话要经历这些环节:意图理解、字段映射、SQL 生成、SQL 执行、结果摘要、回答生成。每个环节都可能出错,而且错误会层层放大:模型生成 SQL 时漏掉一个过滤条件,查询结果就是错的,但看起来还挺像那么回事。
所以基础设施搭建不能只围绕“调用大模型”这一个点,而是要把整条数据链路当成一个系统来设计。模型接入层只负责文字生成,编排层负责控制流程,数据访问层负责安全高效地执行查询,可观测层负责记录每次决策和工具调用。只有每一层边界清晰,后续排查问题时才不会把所有锅都甩给“模型太笨”。
1.2 基础设施的四条原则
我给团队定过四条原则,后面所有搭建工作都围绕它们展开:
- 可替换:模型供应商不能绑定。今天用 A 厂商,明天换成 B 厂商,不能动业务代码。
- 可观察:每次 Agent 调用都要能回放。完整记录 prompt、工具参数、SQL、查询结果、最终回答。
- 可限流:失控查询不能拖垮数据库。无论是 SQL 还是并发请求,都要有兜底机制。
- 可回滚:模型、提示词、工具配置化。线上出问题时能在一分钟之内切回旧版本。
这四条不是理论。可替换保证你不被单一供应商拿捏;可观察保证每次回答错误都能定位是模型问题、工具问题还是数据问题;可限流是生产环境的生死线;可回滚是最后一道安全网。基础设施阶段把这些原则落实,后面迭代才有底气。
1.3 技术选型边界
我们的技术栈是这样定的,你完全可以按自己的环境调整:
- 语言:Python 3.11+。Agent 生态基本都在 Python 这边。
- 编排框架:LangGraph。因为问数流程天然是“生成 SQL、执行、再回复”的状态流转,LangGraph 的状态图模型很适合。
- 工具协议:MCP。后面要接数仓、告警平台、知识库,统一用 MCP 暴露工具,Agent 端不重复造轮子。
- LLM:优先兼容 OpenAI 协议,具体模型可切换,我们主力用 qwen-plus 和 deepseek-chat。
- 数据库:示例用 MySQL 语义,本地开发用 SQLite。生产建议单独账号、只读权限。
- 可观测:外部用 LangSmith,内部保留结构化日志作为兜底。
选型有一个原则:能不自己写框架就不自己写,但底层细节一定要吃透。框架只是帮我们管理复杂度,不能代替我们思考。
2. 开发脚手架与配置管理:一个能持续演进的工程底板
2.1 项目目录:按职责拆模块,而不是按页面拆
基础设施搭建第一步是搭一个可持续演进的目录结构。我的习惯是让“工具”成为独立模块,每增加一个数据源就加一个文件,不破坏主流程。
lcoder_askdb/ ├── app/ │ ├── agent/ # LangGraph 编排层 │ │ ├── graph.py │ │ ├── state.py │ │ └── nodes.py │ ├── tools/ # 工具集,一个文件一个工具 │ │ ├── sql_query.py │ │ └── registry.py │ ├── llm/ # 模型工厂 │ │ ├── factory.py │ │ └── errors.py │ ├── db/ # 数据库访问层 │ │ ├── engine.py │ │ └── safe_query.py │ ├── config.py # 全局配置 │ └── logging_conf.py # 结构化日志 ├── mcp_server/ # MCP 服务端 ├── tests/ ├── pyproject.toml └── .env.example依赖管理我推荐用 uv。它比 pip 快一个数量级,还能直接管理 Python 版本。项目初始化的时候直接uv init,之后所有依赖加进pyproject.toml,配合锁文件保证开发环境一致。这个阶段别省时间,环境不一致带来的坑比想象中多。
2.2 配置中心:别在代码里硬编码任何东西
配置管理的核心工具是 pydantic-settings。它能在启动时自动读取环境变量和 .env 文件,还能做类型校验。问数项目的配置类大概是这样的:
from pydantic_settings import BaseSettings class Settings(BaseSettings): model_config = {"env_file": ".env", "extra": "ignore"} # 模型配置 llm_provider: str = "openai_compatible" llm_model: str = "qwen-plus" llm_base_url: str = "" # 生产环境从环境变量读取 llm_api_key: str = "" llm_temperature: float = 0.1 llm_timeout_seconds: int = 60 # 数据库配置 db_url: str = "sqlite:///./askdb.db" db_pool_size: int = 5 db_max_overflow: int = 10 db_statement_timeout_ms: int = 5000 # 查询限制 query_max_rows: int = 100 query_max_sql_length: int = 2000 # 可观测 langsmith_api_key: str = "" langchain_tracing_v2: bool = False settings = Settings()这里有个实操细节:.env绝对不能进 git,但.env.example必须进仓库。.env.example里写好每个配置项的含义和取值示例,让新同学五分钟之内能跑起来。另外所有配置项集中在config.py里 import,别在业务代码里到处读os.getenv,否则一个月后你自己都记不清哪些变量在哪些地方用到了。
2.3 密钥管理:第一道安全红线
密钥管理是基础设施里最容易被忽视的部分。很多项目把 API Key 写在.env里就算完成,但生产环境还应该做到:密钥由部署平台统一注入环境变量,开发者本地使用个人 Key,代码仓库里任何位置不允许出现明文密钥。
我们在 CI 里加了扫描工具,只要检测到疑似的高权限密钥就会让流水线失败。判断标准就一条:仓库泄露后,攻击者能不能拿到模型调用额度或者数据库访问权。如果能,这就算安全事故。密钥这件事没有中间态,宁可严格一万次,也不能松一次。
3. 模型接入层:让 Agent 能对话,也能换模型
3.1 统一模型工厂:一次封装,全局复用
模型接入层在问数项目里承担一个核心职责:屏蔽底层差异。不管是 qwen、deepseek 还是 OpenAI,对上层的 Agent 逻辑来说,都只应该是一个“能接收消息并返回文本和工具调用”的黑盒。
我们用工厂函数创建模型实例,核心代码如下:
from langchain_openai import ChatOpenAI from app.config import settings def get_llm(): return ChatOpenAI( model=settings.llm_model, base_url=settings.llm_base_url, api_key=settings.llm_api_key, temperature=settings.llm_temperature, timeout=settings.llm_timeout_seconds, max_retries=1, )这个base_url的设计很关键。很多国产模型厂商提供 OpenAI 兼容接口,只要 base_url 和 model 名字对得上,业务代码一行都不用改。本地开发甚至可以指向 Ollama 跑 7B 小模型。这就是“可替换”原则的落地。
3.2 重试、超时与错误归一化
模型接口不是本地函数,它可能超时、限流、返回 5xx。所以模型层必须统一做三件事:超时控制、重试策略、错误归一化。
一个实用的重试策略是:对网络错误和 5xx 做指数退避重试,初始等待 1 秒,最多重试 3 次;对 429 限流则要根据 Retry-After 头等待;对 4xx 错误不重试,因为重试也没用。所有错误统一包装成LLMError向上抛出,编排层只认这一个异常类型,不跟具体厂商的异常直接打交道。
import time, asyncio from openai import APITimeoutError, RateLimitError async def call_llm_with_retry(func, max_retries=3): delay = 1.0 for attempt in range(max_retries): try: return await func() except (APITimeoutError, RateLimitError) as e: if attempt == max_retries - 1: raise LLMError(f"模型调用失败: {e}") from e await asyncio.sleep(delay) delay *= 2这个封装不要在业务代码里散落,就放在模型工厂文件里。所有get_llm()返回的客户端,后续调用都走统一封装。
3.3 兼容多模型的两个细节
第一,工具调用能力。问数项目必然要让模型调用查询工具,所以选的模型必须支持 function calling。qwen-plus、deepseek-chat 都支持,但某些本地小模型不支持。如果必须用不支持的模型,就要在提示词里要求输出固定 JSON,再在代码里做解析和校验。这个降级方案我在后面问题排查部分会讲到。
第二,temperature 参数。问数任务追求的是精确而非创意,temperature 建议设 0 或 0.1。设太高,模型同一个问题五次生成五种 SQL,根本没法稳定复现。我们团队实测下来,0.1 是准确率和鲁棒性都比较好的取值。
4. Agent 编排运行时:用 LangGraph 把流程变成可控状态机
4.1 为什么是 LangGraph 而不是一个死循环
很多 Agent demo 就是一个 while 循环:调用模型、拿工具结果、再调用模型,直到结束。这么做 demo 没问题,但它不满足生产环境的三点要求:可控、可回放、可中途干预。LangGraph 把流程定义成状态图,每个节点做一类事情,节点之间按条件路由,整个过程都基于显式的状态流转。
问数项目的状态定义如下:
from typing import TypedDict class AskState(TypedDict): question: str # 用户原始问题 history: list # 多轮对话历史 sql: str | None # 生成的 SQL query_result: list | None # 查询结果 answer: str | None # 最终回答 error: str | None # 错误信息定义好状态后,图的结构就是:首先 receive_query 节点接收并预处理问题;然后 analyze 节点让模型基于表结构生成 SQL;接着 execute 节点执行查询工具;最后 respond 节点把结果翻译成自然语言。中间如果 SQL 执行失败,就进入 error_handle 节点修正 SQL 重试一次。
4.2 节点与条件路由的实践
LangGraph 的节点就是一个接收 state、返回新 state 的函数。我习惯把每个节点放在nodes.py里独立成函数,避免一个文件几百行。
from langgraph.graph import StateGraph, END def analyze_node(state: AskState) -> AskState: sql = generate_sql(state["question"], state["history"]) state["sql"] = sql return state def execute_node(state: AskState) -> AskState: result, error = safe_query(state["sql"]) state["query_result"] = result state["error"] = error return state def respond_node(state: AskState) -> AskState: state["answer"] = summarize_answer(state["question"], state["query_result"]) return state graph = StateGraph(AskState) graph.add_node("receive_query", receive_query_node) graph.add_node("analyze", analyze_node) graph.add_node("execute", execute_node) graph.add_node("respond", respond_node) graph.add_edge("receive_query", "analyze") graph.add_edge("analyze", "execute") graph.add_conditional_edges( "execute", lambda state: "respond" if state["error"] is None else "analyze" ) graph.add_edge("respond", END)条件路由的意思就是:如果查询成功,直接生成回答;如果查询失败,回到 analyze 节点重新生成 SQL。但这里要加一个重试次数上限,比如最多重新生成两次,超过就直接返回错误,避免模型死循环消耗 token。
4.3 工具注册与参数校验:把模型关进“笼子”里
问数项目里模型需要调用的是一个查询工具。工具注册阶段最重要的不是“能不能调通”,而是“模型能不能按规矩调”。我们用 LangChain 的@tool装饰器加 Pydantic 参数描述,模型会看到结构化的工具说明。
from langchain_core.tools import tool from pydantic import BaseModel, Field class QueryInput(BaseModel): sql: str = Field(description="要执行的只读 SQL,必须带 LIMIT 100") @tool("safe_query", args_schema=QueryInput) def safe_query_tool(sql: str) -> tuple: """执行只读 SQL 查询,支持 MySQL/SQLite,自动处理超时和行数限制""" return safe_query(sql)这里有个关键设计:工具描述里要明确写“必须带 LIMIT”,schema 里也要带。模型是概率系统,我们通过描述约束它,再通过后端强校验兜底。底线是:即使模型不听话,数据库层面也不能出问题。
5. 数据访问层:安全高效地“问数”
数据访问层是问数项目最核心的底座。前面模型写得再好,这一层出事就是大事故。所以这块我把安全放在第一位,效率放在第二位。
5.1 只读账号与连接池
生产环境必须为 Agent 单独建一个数据库账号,权限只有 SELECT,不给 INSERT、UPDATE、DELETE、DDL。这一步是为了防止提示词注入的最坏情况。即使模型被诱导生成了恶意 SQL,只读账号也会把损失降到最低。
连接池方面,直接用 SQLAlchemy 统一管理。根据预估并发设置连接池大小,我一般给一个保守值:
from sqlalchemy import create_engine engine = create_engine( settings.db_url, pool_size=settings.db_pool_size, max_overflow=settings.db_max_overflow, pool_pre_ping=True, pool_recycle=3600, )pool_pre_ping=True很重要,它会在每次从连接池取出连接时先做一次轻量检查,避免拿到坏连接。pool_recycle=3600是防止数据库端超时断开。
5.2 SQL 安全校验:正则只是第一步
很多教程只教“用正则过滤 DELETE、DROP”,但实际生产里正则不够。模型可能生成多语句 SQL,比如SELECT ...; DELETE ...,单条正则很难覆盖所有变体。更可靠的方案是用 sqlglot 这类 SQL 解析器,把 SQL 解析成 AST,再做以下几项检查:
- 只允许 SELECT 语句,其他语句直接拒绝。
- 禁止多语句拼接,只取第一个语句,后面的一律丢弃。
- 强制限制返回行数,如果原 SQL 没有 LIMIT,在外面包一层子查询加上 LIMIT。
- 设置数据库 statement timeout,防止慢查询拖垮数据库。
同时在查询工具内部加一层后端兜底:
from sqlglot import parse_one, exp def validate_sql(sql: str) -> str: sql = sql.strip().rstrip(";") try: statement = parse_one(sql) except Exception as e: raise ValueError(f"SQL 解析失败: {e}") if not isinstance(statement, exp.Select): raise ValueError("只允许 SELECT 查询") if ";" in sql: raise ValueError("不允许多语句执行") return sql加一层统一的safe_query函数,把校验、超时、行数限制全部封装进去。Agent 只能通过这个函数访问数据库,不能直连 engine。
5.3 表结构与元数据注入
模型要生成正确的 SQL,前提是它知道数据库里有什么。我们会在 analyze 节点的提示词里注入核心表的建表语句和注释。注入的内容包括:表名、字段名、字段类型、字段注释、索引信息、常用过滤条件示例。
比如 prompt 里会有这么一段:
数据库表结构: - 表名: sales_order - 字段: - id BIGINT 主键 - region VARCHAR 区域:华东、华北、华南等 - amount DECIMAL(10,2) 订单金额 - order_date DATE 下单日期 - created_at DATETIME 创建时间 注意:查询时务必加上时间范围条件,避免全表扫描。表结构可以直接写进系统提示词,也可以作为工具参数描述的一部分。如果数据库表非常多,不可能全部塞进 context,就需要通过检索的方式筛选相关表,这正好用到后面的 RAG 辅助。
5.4 轻量级 RAG 辅助字段映射
用户问题里的词往往和数据库字段名对不上。用户说“毛利”,表里叫gross_profit;说“客户”,表里叫customer_name。这个映射问题单靠提示词很难完全解决。我们的做法是把字段名、字段注释、业务常用别名做成向量索引,在生成 SQL 前先做一次相似度召回,把候选字段信息注入到 analyze 节点。
这一步算“锦上添花”的基础设施,可以在第一版跑通后再加。最开始我建议先把固定表结构注入提示词,跑通全链路,再迭代 RAG 辅助。基础设施搭建讲究“先简后繁”,别一天就想上满所有能力。
6. MCP 协议接入:让外部工具变成标准件
6.1 为什么选 MCP 协议
MCP 全称 Model Context Protocol,它解决的是 Agent 和外部工具之间的标准化问题。没有 MCP 之前,接一个数据库写一套自定义封装,接一个告警平台再写一套,每个 Agent 都在重复造轮子。MCP 把工具暴露成标准服务,Agent 端只需要统一连接 MCP Server,就能发现并调用所有暴露出来的工具。
问数项目用 MCP 的好处是:后续要接数据仓库、BI 报表、知识库、企业 API 时,每个模块做成独立的 MCP Server,Agent 编排层不需要跟着改。这是基础设施层面的“面向未来设计”。
6.2 用 FastMCP 暴露一个查询工具
我们用 FastMCP 框架来写 MCP Server,代码非常简洁。下面是一个最小可用的查询工具服务端:
from fastmcp import FastMCP from app.db.safe_query import safe_query mcp = FastMCP("askdb-tools") @mcp.tool() def query_sales(region: str | None = None, start_date: str | None = None) -> list[dict]: """按区域和时间范围查询销售数据,返回最多 100 条""" sql = "SELECT * FROM sales_order WHERE 1=1" if region: sql += f" AND region = '{region}'" if start_date: sql += f" AND order_date >= '{start_date}'" sql += " LIMIT 100" return safe_query(sql) if __name__ == "__main__": mcp.run()这里故意用了参数化的方式生成 SQL,而不是让模型直接传入完整 SQL。虽然灵活性降低,但安全性和可控性大幅提高。在这个阶段,我们允许两种模式并行:一种是模型直接生成 SQL 并由校验层兜底,另一种是通过工具参数约束查询维度。生产环境我更推荐后者。
6.3 Agent 端连接 MCP Server
Agent 端可以通过 langchain-mcp-adapters 把 MCP 工具转换成 LangChain 能识别的工具。基础设施阶段,我们只需要验证一条链路:MCP Server 启动后,Agent 能从工具列表里发现query_sales,能正确传递参数,能拿到返回结果。链路跑通后,再考虑同时挂多个 MCP Server 的问题。
有一个坑提前提醒:不同的 MCP Server 如果工具名重复,Agent 端会有命名冲突。我在工具命名时统一加了业务前缀,比如sales_query、schema_list,而不是笼统的query。这个习惯在后端接入更多数据源时会省很多事。
7. 可观测性与评估:没有日志的 Agent 没法修
7.1 结构化日志:每条记录都能回放
Agent 应用和普通 Web 应用不一样,它的一次请求会触发多次模型调用和多次工具调用,链路长且非线性。普通文本日志根本没法看。必须从一开始就上结构化日志,推荐 JSON 行格式,每个事件一行 JSON。
关键事件我会打五类:llm_request、tool_call、tool_result、node_start、node_end。每个事件都挂上同一个trace_id。这样排查问题时,可以用 trace_id 把一次完整交互串起来。日志字段至少包含:时间、trace_id、节点名、事件类型、关键数据和耗时。
import logging, json def log_event(trace_id: str, event_type: str, node: str, data: dict): logging.getLogger("agent").info(json.dumps({ "trace_id": trace_id, "event": event_type, "node": node, **data, }, ensure_ascii=False))一个基本原则:任何可能影响最终回答的因素都要记录。模型用的提示词要记录,工具传入的参数要记录,SQL 执行耗时要记录。宁可日志多得翻不过来,也不能在出问题时什么都查不到。
7.2 接入 LangSmith 做完整链路追踪
如果条件允许,强烈建议接入 LangSmith。它对 LangChain/LangGraph 的应用几乎是零侵入式接入,自动捕获每一步的模型调用、工具调用、token 消耗和耗时。我在 config.py 里留了开关,开发环境关掉,测试和生产环境打开。
接入步骤很简单:设置环境变量LANGCHAIN_TRACING_V2=true、LANGCHAIN_API_KEY=...,再设置 project name。之后每次运行 Agent,就能在 LangSmith 里看到完整的链路回放。它最大的价值是快速定位“模型为什么生成这个 SQL”:你能看到系统提示词、用户问题、模型中间推理、工具返回结果,全部一清二楚。
如果你不想依赖外部服务,也可以用 Langfuse 或者自建一个 trace 表。但对小团队来说,先上 LangSmith 以最快速度获得可观测性,是性价比最高的选择。
7.3 准备评估集:让每次优化都可回归
基础设施阶段就要开始建评估集,不要等到上线之后。我们准备了 20 个 golden 问题,覆盖常见业务场景:简单汇总、多条件过滤、时间对比、字段映射、无法回答的问题。每个问题都标注了期望的 SQL 要点和期望答案。
question,expected_sql_note,expected_answer 上个月华东区销售额是多少,必须包含 region='华东' 和订单日期在当月,返回销售总额 哪个客户贡献了最多的订单,必须按 customer_name 分组后 sum(amount) 排序,返回客户名和金额 查询所有订单总量,不允许全表扫描,返回总数每次修改模型、提示词或工具定义后,跑一遍评估集。这个动作能让“感觉好多了”变成“指标从 82% 涨到 87%”。没有评估集的情况下做 Prompt 调优,约等于蒙着眼睛开车。
8. 常见问题与排查技巧实录
8.1 模型输出 JSON 不稳定,工具解析频繁失败
现象:模型不返回标准工具调用格式,而是把工具参数写成 Markdown 代码块或附带解释文字,解析时直接报错。
后台日志会暴露一个规律:这类问题几乎都出现在更换模型之后。不同厂商对工具调用的实现不完全一样,个别模型在复杂场景下会退化到“用文本回答而不是调用工具”。我的排查步骤是:
- 先看 LangSmith trace,确认这个问题出现在模型调用环节还是工具解析环节。
- 确认模型版本是否支持 function calling,确认 base_url 指向正确。
- 如果模型本身支持但偶尔不稳定,可以在提示词里加强约束,并做文本解析兜底:把返回内容里的 JSON 片段用正则提取出来,再交给 Pydantic 解析。
- 如果多次重试仍失败,走异常处理节点,返回“暂时无法回答,请联系管理员”。
8.2 SQL 多次重试,单次请求累计耗时过长
现象:一条查询请求总耗时超过 60 秒,原因不是单次 SQL 慢,而是模型生成 SQL 后执行失败,跳到 analyze 节点重新生成,循环了好几次。每次都消耗十几秒,叠加起来就超时了。
我的解决办法是三层超时控制:
- LangGraph 整体设置递归次数上限,比如最多重试 2 次,超过就终止。
- analyze 节点的模型调用设置 20 秒超时,执行节点设置 5 秒数据库超时。
- 外部请求入口设置 90 秒总超时。
此外重试逻辑里增加“失败原因透传”,把上一次的数据库错误信息拼进第二次生成的提示词里。比如告诉模型“刚才执行的 SQL 报错:Unknown column 'gross_profit',请检查字段名”。这样模型重试时才有方向,而不是从头开始瞎猜。
8.3 Token 限制导致工具 schema 过大
现象:表多字段多的时候,把所有表结构写进提示词,prompt 越来越长,最终触达上下文窗口。更麻烦的是,模型被大量无关表结构干扰,生成 SQL 的准确率反而下降。
解决思路是“按需注入”:
- 先用 5 万条左右的核心表结构缓存到本地,作为固定的系统提示词。
- 剩下的表通过向量检索召回,只注入与用户问题相关的表和字段。
- 字段描述做压缩,每条只保留字段名、类型、中文注释,去掉冗余说明。
实测下来,按需注入比一股脑全部塞进 context 的准确率高不少,也节省 token 成本。
8.4 并发测试时数据库连接被打满
现象:压测脚本同时发 50 个请求,数据库连接池打满,排队请求直接超时,甚至拖垮了同库的其他业务。
这个问题的排查方向不应该是单纯调大连接池。要分三层处理:
- 数据库只读账号数量有限,先压住 Agent 入口的并发,比如用队列限流,同一时刻最多处理 10 个请求。
- 针对 LLM 调用做限流和排队,避免瞬时打爆模型接口的 QPS。
- 数据库层给 Agent 账号限制最大连接数,防止它占用过多资源,影响其他业务。
同时把慢查询和全表扫描 SQL 在数据库端用审计日志记录下来,回头分析哪些表和查询模式需要优化。基础设施阶段不做这步,上线后被业务方投诉“系统变慢”时再处理就非常被动了。
写到这里,问数项目的基础设施部分基本搭完了。我个人在实际操作中的体会是,基础设施是最不显眼但决定性最强的阶段,后面 90% 的稳定性问题都能在这里找到根因。这块省下的时间,后面会用十倍的时间还回去。下一步我们会在这套底座上正式实现问数智能体的各个业务节点,包括意图路由、SQL 生成优化、多轮对话记忆、结果可视化,到那一步再继续分享实战过程。