Text-to-SQL 项目落地实战

Text-to-SQL 项目落地实战
AI Agent 工程实践教程 · 第 10 章
把 Text-to-SQL 从流程讲解推进到项目目录、代码组织、调试观测和落地实现。
第十天_Text-to-SQL项目落地实战
第十天:Text-to-SQL 项目落地实战
这一节课我们不再只讲“Text-to-SQL 是什么”,而是看项目里已经写好的 Text-to-SQL 代码。
今天要学会一件事:
用户输入一句自然语言问题后,系统是怎样一步一步生成 SQL、执行查询,并把结果变成图表和分析结论的。
这份文档是按当前 YanQue 项目的代码逻辑写的。
先强调一点:
当前代码里没有
QueryPlan节点,也没有单独的“生成查询计划”步骤。
本节课只按现在代码里的真实流程讲:
classify_question
↓
retrieve_metrics
↓
retrieve_table_catalog
↓
route_tables
↓
retrieve_selected_table_details
↓
judge_and_generate_sql
↓
execute_sql
↓
analyze_query_result
所以课堂里不要再讲 QueryPlan。当前项目里对应的位置是:
judge_and_generate_sql
它直接根据指标上下文、选中表详情和上一次执行错误,判断是否生成 SQL,或者返回 need_metric、need_schema、need_clarification 等分支。
1. 今天学完要会什么
学完这一节,应该能回答下面几个问题:
- Text-to-SQL 为什么不能直接让大模型连数据库?
- 用户在前端提问后,请求经过了哪些代码文件?
- LangGraph 在这个功能里负责什么?
- 系统为什么要先做问题分类?
- 指标检索、表路由、表详情加载分别解决什么问题?
- SQL 生成后为什么还要校验?
- Python 为什么不直接查询 MySQL,而是调用 Java 内部接口?
- 查询结果是怎样变成摘要、发现、依据和图表的?
这一节课的重点不是背代码,而是理解一条完整链路。
2. 先看最终效果
后台有一个“数据分析”页面。
用户可以输入:
统计上个月各课程支付成功订单数量,并按课程对比
系统会尝试做这些事:
识别这是一个数据查询问题
找到相关指标口径
找到可能相关的数据表
读取表字段详情
让大模型生成只读 SELECT SQL
校验 SQL 是否安全
调用 Java 内部接口执行 SQL
拿到查询结果
生成分析摘要和图表数据
前端展示结果
整体流程如下:
注意,这不是:
用户问题 -> 大模型随便写 SQL -> 直接查数据库
真实项目里这样做很危险。
更安全的方式是:
模型负责理解和生成
程序负责校验和执行边界
3. 项目代码分成哪几层
先不要急着看每个函数。
我们先从整体结构看。
当前代码分成四层:
| 层 | 可以这样理解 | 主要代码 |
|---|---|---|
| 前端页面 | 用户输入问题、查看结果 | YanQue-Admin-Web/src/pages/TextToSqlPage.tsx |
| Java 管理端 | 接收前端请求,转发给 Python | TextToSqlController.java、PythonTextToSqlClient.java |
| Python AI 服务 | 用 LangGraph 编排 AI 流程 | graph.py、route_nodes.py、各个 node |
| Java 内部执行接口 | 真正执行只读 SQL | InternalTextToSqlController.java、TextToSqlQueryServiceImpl.java |
这几个模块的分工很清楚:
| 模块 | 做什么 | 不做什么 |
|---|---|---|
| 前端 | 提问、展示结果 | 不生成 SQL |
| Java 对外接口 | 接收后台请求 | 不调用大模型 |
| Python | 调模型、跑流程、分析结果 | 不直接连业务数据库 |
| Java 内部接口 | 校验 SQL、查数据库 | 不负责理解自然语言 |
这里有一个非常重要的工程思想:
Python 负责 AI 编排,Java 负责业务数据访问。
4. 一次请求的完整路线
用户点“开始分析”以后,请求会经过下面这条路线:
用代码文件串起来就是:
TextToSqlPage.tsx
↓
textToSqlApi.route()
↓
TextToSqlController.route()
↓
TextToSqlBizImpl.route()
↓
PythonTextToSqlClient.route()
↓
Python: /api/text-to-sql/route
↓
TextToSqlService.process_question()
↓
LangGraph 主流程
↓
JavaTextToSqlQueryClient.execute()
↓
Java: /internal/ai/text-to-sql/execute
↓
TextToSqlQueryServiceImpl.executeReadonlyQuery()
↓
MySQL
↓
返回查询结果
↓
Python 分析结果
↓
前端展示图表和表格
可以把它理解成一次“来回跑”:
前端 -> Java -> Python -> Java 内部接口 -> 数据库 -> Python -> Java -> 前端
常见疑问:
为什么 Python 不直接查数据库?
因为 Java 后端本来就是业务系统的核心边界。
数据库连接、查询超时、默认 LIMIT、内部 token、SQL 安全校验,都放在 Java 侧更稳。
5. 前端页面:用户在哪里提问
前端页面代码:
YanQue-Admin-Web/src/pages/TextToSqlPage.tsx
页面主要有三块:
| 页面区域 | 作用 |
|---|---|
| 提问输入框 | 输入自然语言问题 |
| 补充信息区域 | 当系统需要追问时,让用户补充 |
| 结果展示区域 | 展示摘要、关键发现、图表、表格、SQL 调试信息 |
发起请求的代码是:
const ask = async () => {
const values = await form.validateFields();
setLoading(true);
try {
const data = await textToSqlApi.route(values);
setResult(data);
if (data.interrupted) {
message.info('需要补充信息后继续分析');
}
} finally {
setLoading(false);
}
};
这段代码的意思是:
1. 先校验用户有没有输入问题
2. 调用后端接口
3. 把返回结果保存到 result
4. 如果 interrupted=true,说明系统需要用户补充信息
前端 API 定义在:
YanQue-Admin-Web/src/api/system.ts
代码:
export const textToSqlApi = {
route(data: TextToSqlRouteValues) {
return http.post<never, TextToSqlResult>('/api/textToSql/route', data, { timeout: 180000 });
},
continueQuestion(data: TextToSqlContinueValues) {
return http.post<never, TextToSqlResult>('/api/textToSql/continue', data, { timeout: 180000 });
},
};
这里有两个接口:
| 方法 | 用途 |
|---|---|
route |
第一次提问 |
continueQuestion |
系统追问后,用户补充信息继续分析 |
6. 前端收到什么结果
前端类型定义在:
YanQue-Admin-Web/src/types/system.ts
核心响应类型是 TextToSqlResult。
先记住几个关键字段:
| 字段 | 含义 |
|---|---|
questionType |
问题类型,比如 query、chat、update、unclear |
routeReason |
为什么这样分类 |
finalAnswer |
最终回答 |
action |
SQL 生成节点的动作 |
sql |
Python 侧生成的 SQL |
executedSql |
Java 最终执行的 SQL |
usedTables |
SQL 使用了哪些表 |
usedFields |
SQL 使用了哪些字段 |
columns |
查询结果列 |
rows |
查询结果数据 |
analysis |
分析摘要、发现、依据和图表 |
interrupted |
是否需要用户补充信息 |
前端展示图表时,主要看:
export interface TextToSqlChart {
type: 'bar' | 'line' | 'pie' | 'table' | string;
title: string;
reason: string;
xField?: string;
yFields?: string[];
categoryField?: string;
data?: Record<string, unknown>[];
}
这说明 Python 不只是返回 SQL,还会把数据整理成前端能画图的结构。
7. Java 对外接口:前端先进 Java
Java 对外 Controller:
YanQue-Admin/src/main/java/cn/yanque/models/ai/texttosql/controller/TextToSqlController.java
代码:
@PostMapping("/route")
public ApiResponse<TextToSqlRouteDto.RouteRes> route(@Valid @RequestBody TextToSqlRouteDto.RouteReq req) {
return ApiResponse.success(textToSqlBiz.route(req));
}
@PostMapping("/continue")
public ApiResponse<TextToSqlRouteDto.RouteRes> continueQuestion(@Valid @RequestBody TextToSqlRouteDto.ContinueReq req) {
return ApiResponse.success(textToSqlBiz.continueQuestion(req));
}
Controller 很薄,只做入口。
真正转发给 Python 的逻辑在:
TextToSqlBizImpl.java
代码:
req.setUserQuestion(req.getUserQuestion().trim());
return pythonTextToSqlClient.route(req);
这里做了一件小事:
去掉用户问题前后的空格
然后交给:
PythonTextToSqlClient.java
它负责用 HTTP 调 Python 服务。
8. Java 怎样调用 Python
代码位置:
YanQue-Admin/src/main/java/cn/yanque/models/ai/texttosql/client/PythonTextToSqlClient.java
核心方法:
public TextToSqlRouteDto.RouteRes route(TextToSqlRouteDto.RouteReq req) {
return post(buildUrl(textToSqlProperties.getRoutePath()), req, "Text-to-SQL服务调用失败", "Text-to-SQL服务调用异常");
}
它会读取配置:
text-to-sql:
base-url: http://127.0.0.1:8000
route-path: /api/text-to-sql/route
continue-path: /api/text-to-sql/continue
connect-timeout-seconds: 3
request-timeout-seconds: 120
default-limit: 50
max-limit: 200
query-timeout-seconds: 10
所以 Java 对前端暴露的是:
/api/textToSql/route
Java 调 Python 的是:
/api/text-to-sql/route
这两个路径不要混淆。
9. Python API:进入 AI 流程
Python 入口:
YanQue-AI/src/yanque_ai/api/text_to_sql_api.py
代码:
@router.post("/route", response_model=TextToSqlRouteResponse)
def route_question(request: TextToSqlRouteRequest):
text_to_sql_service = get_text_to_sql_service()
return text_to_sql_service.process_question(request.user_question, request.conversation_id)
这里也很薄。
真正跑流程的是:
YanQue-AI/src/yanque_ai/text_to_sql/service/text_to_sql_service.py
核心代码:
result = self._graph.invoke(
{
"user_question": user_question,
"original_question": user_question,
"conversation_id": conversation_id,
"clarification_history": [],
},
config=self._graph_config(conversation_id),
)
要注意这个字段:
"conversation_id": conversation_id
它用于多轮补充信息。
如果系统觉得问题不清楚,会暂停流程,等用户补充。
用户补充后,再用同一个 conversation_id 继续。
10. LangGraph 是什么作用
Text-to-SQL 不是一个简单函数。
它有很多步骤:
问题分类
指标检索
表目录加载
选表
表详情加载
SQL 判断与生成
SQL 执行
结果分析
这些步骤之间还有分支:
普通问答 -> 不生成 SQL
修改数据 -> 拒绝
问题不清楚 -> 追问
缺指标 -> 返回缺指标
缺表结构 -> 返回缺表结构
SQL 成功 -> 执行
SQL 失败 -> 有限修正
所以项目用 LangGraph 管这条流程。
主图代码:
YanQue-AI/src/yanque_ai/text_to_sql/graph.py
图里的节点包括:
| 节点名 | 可以这样理解 |
|---|---|
classify_question |
先判断用户问题属于哪一类 |
retrieve_metrics |
查指标口径 |
retrieve_table_catalog |
加载轻量表目录 |
route_tables |
让模型从表目录里选表 |
retrieve_selected_table_details |
加载选中表的字段详情 |
judge_and_generate_sql |
判断能不能生成 SQL,能就生成 |
execute_sql |
调 Java 内部接口执行 SQL |
analyze_query_result |
分析查询结果并生成图表结构 |
need_clarification |
需要用户补充信息 |
need_metric |
缺指标 |
need_schema |
缺表结构 |
update_not_supported |
拒绝新增、修改、删除 |
课堂上可以重点看这段:
graph_builder.add_edge(START, "classify_question")
graph_builder.add_conditional_edges(
"classify_question",
route_nodes.route_after_classification,
{
"chat": "chat",
"query": "query_parallel_start",
"update": "update_not_supported",
"unclear": "need_clarification",
},
)
意思是:
所有问题先进 classify_question
分类结果不同,后面的路线不同
这就是 LangGraph 的价值。
它不是替我们写 SQL,而是帮我们管理流程。
11. State:流程里的共享数据
代码位置:
YanQue-AI/src/yanque_ai/text_to_sql/state.py
TextToSqlRouteState 可以理解成:
这次 Text-to-SQL 请求的临时工作台。
每个节点都会往里面写一点东西。
例如:
| 阶段 | 写入 State 的字段 |
|---|---|
| 问题分类 | question_type、route_reason |
| 指标检索 | metric_hits、metric_context |
| 表路由 | selected_tables、table_selection_reason |
| 表详情 | selected_table_details、selected_table_schema_context |
| SQL 生成 | generated_sql、sql_action、used_tables、used_fields |
| SQL 执行 | query_columns、query_rows、executed_sql |
| 结果分析 | query_result_analysis、analysis_summary |
| 追问补充 | clarification_history、clarification_question |
比如 SQL 生成节点写入:
generated_sql
used_tables
used_fields
sql_action
执行节点再从 State 里拿这些字段去执行。
这样每个节点只负责自己的事情。
12. 第一步:问题分类
代码位置:
YanQue-AI/src/yanque_ai/text_to_sql/nodes/question_classifier.py
分类器只做一件事:
判断用户问题属于哪一类。
当前代码支持四类:
| 类型 | 含义 | 后续 |
|---|---|---|
chat |
普通问答 | 不进入 SQL 链路 |
query |
查询、统计、分析业务数据 | 进入 Text-to-SQL |
update |
新增、修改、删除、清空 | 拒绝 |
unclear |
问题不清楚 | 追问用户 |
Prompt 要求模型只返回 JSON:
{
"questionType": "query",
"routeReason": "用户希望统计已有业务数据。"
}
为什么第一步要分类?
因为不是所有问题都应该生成 SQL。
比如:
把昨天的订单状态都改成成功
这是修改数据。
系统必须拒绝。
当前代码会走:
update_not_supported
返回:
当前暂不支持新增、修改和删除操作。
这是安全设计,不是报错。
13. 第二步:指标检索
代码位置:
YanQue-AI/src/yanque_ai/text_to_sql/nodes/metric_retrieval_node.py
有些问题表面上是自然语言,实际上是在问业务指标。
比如:
支付成功订单数
退款率
客单价
订单总量
这些词不一定是数据库字段。
系统需要知道它们的计算口径。
当前代码会根据配置读取 Text-to-SQL 专用指标知识库:
knowledge_base_id = self._settings.text_to_sql_metric_knowledge_base_id
然后调用知识库检索:
response = self._knowledge_service.search(
KnowledgeSearchRequest(
knowledgeBaseId=knowledge_base_id,
question=user_question,
limit=self._settings.text_to_sql_metric_top_k,
)
)
如果开启了 rerank,还会重排:
rerank_result = self._metric_reranker.rerank(user_question, response.hits)
最后拼成:
metric_context
给后面的 SQL 生成节点使用。
如果指标检索失败,当前代码不会让请求直接失败:
except Exception:
return MetricRetrievalResult()
可以这样理解:
指标检索是补充上下文,失败了先继续走,后面 SQL 生成节点再判断信息够不够。
14. 第三步:加载表目录
代码位置:
YanQue-AI/src/yanque_ai/text_to_sql/nodes/table_catalog_service.py
表目录文件:
YanQue-AI/src/yanque_ai/text_to_sql/metadata/table_catalog.json
表目录不是完整 DDL。
它是轻量信息:
表名
表说明
业务域
主键
关联关系
为什么不一开始就把所有表字段都给模型?
因为字段太多会浪费 token,也会干扰模型。
当前代码先加载轻量表目录:
raw_data = json.loads(catalog_path.read_text(encoding="utf-8"))
return [TableCatalogItem.model_validate(item) for item in raw_data]
然后把目录转成 JSON 字符串:
context=json.dumps(
[table.model_dump(by_alias=True) for table in tables],
ensure_ascii=False,
)
这个 table_catalog_context 会给表路由节点使用。
15. 第四步:表路由
代码位置:
YanQue-AI/src/yanque_ai/text_to_sql/nodes/table_router.py
表路由的任务是:
从表目录中选择本次问题最可能用到的表。
默认 Prompt 里有几个限制:
最多选择 3 张表
只能返回目录中已有的 tableName
表目录只包含表简介和关联关系,不包含字段 DDL
输出结构:
{
"selectedTables": ["order_payment", "order_product"],
"reason": "用户需要按课程产品统计支付成功订单,需要订单表和产品表。"
}
当前代码还会过滤模型返回的表名:
valid_table_names = {table.table_name for table in table_catalog}
selected_tables = [
table_name for table_name in selection.selected_tables if table_name in valid_table_names
]
也就是说,模型返回一个不存在的表名,也不会进入后续流程。
这一步的图示:
16. 第五步:加载选中表详情
代码位置:
YanQue-AI/src/yanque_ai/text_to_sql/nodes/table_detail_retrieval_node.py
详细表结构文件在:
YanQue-AI/src/yanque_ai/text_to_sql/metadata/tables/*.json
例如:
metadata/tables/order_payment.json
metadata/tables/order_product.json
表详情包含:
| 内容 | 例子 |
|---|---|
| 表名 | order_payment |
| 字段 | status、pay_success_time、order_amount |
| 关联关系 | order_payment.product_id -> order_product.id |
| 查询规则 | blockedFields、maskedFields、maxRows |
| 索引 | uk_order_no、idx_student_phone |
当前 order_payment.json 里有这些字段:
id
order_no
student_phone
student_name
product_id
order_amount
refunded_amount
prepay_order_no
status
unique_order_no
pay_success_time
created_at
updated_at
注意,里面没有:
paid_at
product_name
所以后面生成 SQL 时不能使用这些不存在的字段。
加载表详情时还有一层路径保护:
if not re.fullmatch(r"[A-Za-z0-9_]+", table_name):
return None
这防止异常表名拼成奇怪的文件路径。
17. 第六步:SQL 判断与生成
代码位置:
YanQue-AI/src/yanque_ai/text_to_sql/nodes/sql_generation_node.py
这个节点的名字虽然叫 SQL Generation,但它不只是生成 SQL。
它先判断:
现在的信息够不够生成 SQL?
它允许返回这些 action:
| action | 含义 |
|---|---|
generate_sql |
信息足够,生成 SQL |
need_metric |
缺指标定义 |
need_schema |
缺表、字段或关联关系 |
need_clarification |
用户问题缺少条件,需要追问 |
out_of_scope |
不是本系统业务数据 |
unsupported |
不支持的操作 |
模型输出必须是 JSON:
{
"action": "generate_sql",
"sql": "SELECT ...",
"reason": "已经根据指标和表结构生成只读 SQL。",
"missingInfo": [],
"usedTables": ["order_payment"],
"usedFields": ["status", "pay_success_time", "id"]
}
如果不能生成,就不能夹带 SQL:
{
"action": "need_clarification",
"sql": "",
"reason": "用户没有说明统计时间范围。",
"missingInfo": ["统计时间范围"],
"usedTables": [],
"usedFields": []
}
要重点理解:
SQL 生成节点的输出不是一段 SQL 文本,而是一份结构化决策。
18. Python 侧先做 SQL 安全校验
模型返回 generate_sql 后,代码不会直接执行。
先进入:
_validate_read_only_sql()
它会校验:
| 校验项 | 目的 |
|---|---|
必须以 SELECT 开头 |
只允许查询 |
| 不允许分号 | 防止多语句 |
| 不允许注释 | 防止绕过 |
| 不允许危险关键字 | 拦截 INSERT、UPDATE、DELETE、DROP 等 |
| 表名必须在选中表详情里 | 防止模型编表 |
| 字段必须在表详情里 | 防止模型编字段 |
usedTables 要覆盖 SQL 实际表 |
防止声明和 SQL 不一致 |
关键代码:
if not sql or not re.match(r"^SELECT\b", sql, flags=re.IGNORECASE):
return "只允许生成单条 SELECT 语句"
危险关键字:
if ";" in sql or "--" in sql or "/*" in sql or self._FORBIDDEN_KEYWORDS.search(sql):
return "SQL 包含非只读语句或注释"
表名白名单:
actual_tables = set(
re.findall(r"\b(?:FROM|JOIN)\s+`?([A-Za-z_][A-Za-z0-9_]*)`?", sql, flags=re.IGNORECASE)
)
如果校验失败,当前代码会把结果降级成:
need_schema
意思是:
这个 SQL 不能放行,先不要执行。
19. 第七步:Python 调 Java 执行 SQL
代码位置:
YanQue-AI/src/yanque_ai/text_to_sql/nodes/sql_execution_node.py
Python 通过这个类调用 Java:
class JavaTextToSqlQueryClient:
请求体:
{
"sql": "SELECT ...",
"usedTables": ["order_payment", "order_product"]
}
如果配置了内部 token,会带上:
headers["X-Internal-Token"] = self._settings.java_internal_token
这里再次强调:
Python 不直接查数据库。
它只把已经通过 Python 基础校验的 SQL 交给 Java 内部接口。
20. Java 内部接口:真正查询数据库
Java 内部接口代码:
YanQue-Admin/src/main/java/cn/yanque/models/ai/texttosql/controller/InternalTextToSqlController.java
接口路径:
/internal/ai/text-to-sql/execute
它不是给前端调用的。
第一步先校验内部 token:
validateInternalToken(token, request);
如果没有配置 token,本地开发只允许本机调用。
真正执行 SQL 的代码:
YanQue-Admin/src/main/java/cn/yanque/models/ai/texttosql/service/impl/TextToSqlQueryServiceImpl.java
执行前,Java 再做一层校验:
Java 校验包括:
| 校验 | 代码方法 |
|---|---|
| SQL 不能为空 | normalizeSql() |
只允许 select |
validateReadonlySql() |
| 不允许分号、注释 | validateReadonlySql() |
| 不允许危险关键字 | DANGEROUS_KEYWORD_PATTERN |
SQL 中的表必须在 usedTables 中 |
validateUsedTables() |
| 没有 LIMIT 就追加默认 LIMIT | applyLimit() |
| LIMIT 超过最大值就改小 | applyLimit() |
| 设置查询超时 | jdbcTemplate.setQueryTimeout() |
关键代码:
if (!lowerSql.startsWith("select ")) {
throw BusinessException.ParamsError.newInstance("Text-to-SQL 只允许执行 SELECT 查询");
}
追加 LIMIT:
if (!matcher.find()) {
int defaultLimit = textToSqlProperties.getDefaultLimit() == null ? 50 : textToSqlProperties.getDefaultLimit();
return sql + " LIMIT " + Math.min(defaultLimit, maxLimit);
}
所以返回结果里会有两个 SQL:
| 字段 | 含义 |
|---|---|
sql |
Python 生成的 SQL |
executedSql |
Java 最终执行的 SQL,可能加过 LIMIT |
21. SQL 执行失败怎么办
当前代码不是无限重试。
它只对特定 SQL 语法错误做有限修正。
代码在:
YanQue-AI/src/yanque_ai/text_to_sql/route_nodes.py
关键常量:
SQL_REGENERATE_EXCEPTION_CLASSES = {"BadSqlGrammarException"}
MAX_SQL_REGENERATE_ATTEMPTS = 3
也就是说:
只有 Java 返回 BadSqlGrammarException
并且重试次数还没超过 3 次
才会回到 SQL 生成节点重新生成
图示:
这也是一个很重要的工程习惯:
修正可以有,但必须有上限。
22. 问题不清楚时怎么追问
如果用户只说:
看一下订单数据
这个问题不够明确。
系统不知道:
看什么指标?
看哪个时间范围?
按不按课程分组?
这时 SQL 生成节点可能返回:
{
"action": "need_clarification",
"sql": "",
"reason": "用户问题缺少统计指标和时间范围。",
"missingInfo": ["统计指标", "时间范围"]
}
图会进入:
need_clarification
代码位置:
YanQue-AI/src/yanque_ai/text_to_sql/nodes/clarification_node.py
它会生成追问:
请补充以下信息:统计指标;时间范围。
LangGraph 会暂停:
answer = interrupt(clarification_request.to_interrupt_payload())
前端看到:
interrupted = true
就展示补充信息输入框。
用户补充后,前端调用:
/api/textToSql/continue
Python 用同一个 conversation_id 恢复图:
Command(resume={"userAnswer": user_answer})
补充信息会和原问题合并,然后重新进入检索和 SQL 生成流程。
23. 结果分析:把数据变成用户能看懂的答案
代码位置:
YanQue-AI/src/yanque_ai/text_to_sql/nodes/query_result_analysis_node.py
SQL 执行成功后,系统拿到:
columns
rows
row_count
结果分析节点会把这些数据变成:
| 字段 | 含义 |
|---|---|
summary |
一句话摘要 |
findings |
关键发现 |
basis |
分析依据 |
chart |
前端可渲染的图表配置 |
warnings |
风险提醒 |
示例结构:
{
"summary": "查询成功,共返回 3 行数据。",
"findings": [
"课程 A 的支付成功订单数最高。"
],
"basis": [
"基于 SQL 返回的 3 行数据进行分析。"
],
"chart": {
"type": "bar",
"title": "各课程支付成功订单数",
"xField": "course_content",
"yFields": ["paid_order_count"]
}
}
代码还会校验图表字段:
if decision.chart.x_field and decision.chart.x_field not in chart_columns:
raise ValueError(...)
也就是说,模型不能随便编一个前端不存在的字段来画图。
如果没有配置分析模型,代码会返回基础表格分析:
查询成功,共返回 N 行数据。
这保证了:
SQL 查询成功以后,即使分析模型不可用,前端也能展示基础结果。
24. 用一个真实字段例子串起来
现在用当前元数据里的真实字段串一次。
用户问:
统计上个月各课程支付成功订单数量,并按课程对比
如果表路由选中:
order_payment
order_product
那么 SQL 生成节点只能使用这两张表详情 JSON 里有的字段。
当前元数据里相关字段是:
| 表 | 真实字段 |
|---|---|
order_payment |
id、product_id、status、pay_success_time、created_at |
order_product |
id、course_content、teaching_mode、price |
所以不能写:
product_name
paid_at
因为它们不在当前元数据里。
符合当前元数据的 SQL 示例:
SELECT
p.course_content AS course_content,
COUNT(o.id) AS paid_order_count
FROM order_payment o
JOIN order_product p ON o.product_id = p.id
WHERE o.status = 'SUCCESS'
AND o.pay_success_time >= '2026-06-01 00:00:00'
AND o.pay_success_time < '2026-07-01 00:00:00'
GROUP BY p.course_content
ORDER BY paid_order_count DESC
如果没有 LIMIT,Java 最终执行时会追加:
LIMIT 50
所以 executedSql 可能是:
SELECT
p.course_content AS course_content,
COUNT(o.id) AS paid_order_count
FROM order_payment o
JOIN order_product p ON o.product_id = p.id
WHERE o.status = 'SUCCESS'
AND o.pay_success_time >= '2026-06-01 00:00:00'
AND o.pay_success_time < '2026-07-01 00:00:00'
GROUP BY p.course_content
ORDER BY paid_order_count DESC
LIMIT 50
要注意:
课堂里看到的 SQL 字段,必须能回到
metadata/tables/*.json里找到。
这是 Text-to-SQL 可靠性的关键。
第十天_LangSmith调试与观测入门
第十天补:LangSmith 调试与观测入门
前面我们已经学了 LangChain 和 LangGraph。
现在我们已经知道:
Prompt 可以组织输入
Model 可以调用大模型
Parser 可以解析输出
LangGraph 可以把多个步骤编排成流程
但是项目真正跑起来以后,会遇到一个新问题:
AI 回答错了,我们怎么知道是哪一步错了?
比如:
Prompt 拼错了?
历史消息太多了?
知识库没召回?
工具参数抽错了?
模型输出 JSON 格式错了?
某个节点太慢了?
如果只看控制台日志,很难看清楚一整条 AI 调用链。
这就是 LangSmith 要解决的问题。
1. LangSmith 是什么
一句话:
LangSmith 是 LangChain 官方提供的 AI 应用调试、追踪和监控平台。
它不是大模型。
它也不是 LangChain 的替代品。
它更像是 AI 应用的“调试控制台”。
LangSmith 官方文档对 Observability 的描述是:它可以让我们看到 LLM 应用从单次 trace 到生产整体指标的完整情况。官方也说明,LangSmith 可以和多种框架和模型提供商集成,包括 LangChain、OpenAI、Anthropic 等。参考官方文档:LangSmith Observability。
可以先这样理解:
| 工具 | 作用 |
|---|---|
| LangChain | 组织 Prompt、Model、Parser、Tool |
| LangGraph | 编排多节点、多分支、多轮流程 |
| LangSmith | 观察和调试这些链路到底怎么跑 |
图示如下:
2. 为什么项目里需要 LangSmith
普通后端接口出错时,我们可以看:
请求参数
SQL
异常堆栈
日志
但是 AI 应用出错时,问题经常不是一个异常,而是某一步“看起来能跑,但结果不对”。
例如:
用户问:今天有什么课?
AI 回答:没有课。
原因可能有很多:
| 可能原因 | 需要看什么 |
|---|---|
| 工具没有被调用 | 模型是否选择了工具 |
| 工具参数错了 | 工具调用参数是什么 |
| Java 工具接口没返回数据 | 工具返回结果是什么 |
| Prompt 没把工具结果交给模型 | 最终 Prompt 是什么 |
| 模型理解错了 | 模型输入和输出是什么 |
LangSmith 可以把这些步骤展示成一棵 Trace 树。
3. 什么是 Trace
Trace 可以理解成:
一次完整 AI 调用的运行记录。
一次 Trace 里可以包含很多子步骤。
例如一次学生问答可能有:
Root Trace:学生提问
├─ Prompt:组装系统提示词和历史消息
├─ LLM:判断是否需要工具
├─ Tool:调用 Java 查课表
├─ LLM:根据工具结果生成最终回答
└─ Parser:整理输出
图示如下:
这里要记住:
Trace 不是新的业务功能,它是一次 AI 调用的运行轨迹。
4. LangSmith 能看到什么
打开一条 Trace 后,通常可以看到:
| 内容 | 说明 |
|---|---|
| 输入 | 用户问题、变量、上下文 |
| 输出 | 模型回答、解析后的结果 |
| Prompt | 最终发给模型的消息 |
| Model | 调用了哪个模型 |
| 时间 | 每一步耗时 |
| Token | 输入输出 token 使用情况 |
| 错误 | 哪个节点报错 |
| 子步骤 | Prompt、模型、工具、检索等嵌套过程 |
| 标签和元数据 | 项目名、环境、用户、会话等辅助信息 |
在我们项目里,LangSmith 特别适合用来排查:
学生问答为什么没走知识库
工具调用为什么参数不对
Text-to-SQL 为什么分类成 chat
表路由为什么选错表
SQL 生成模型为什么返回 need_schema
结果分析为什么没有生成图表
5. 怎么开通 LangSmith
使用前需要两件事:
LangSmith 账号
LangSmith API Key
官方文档说明,可以在 smith.langchain.com 注册账号,支持 Google、GitHub 和邮箱登录;创建 API Key 后,要复制保存,因为 key 只会展示一次。参考官方文档:Create an account and API key。
步骤:
- 打开 smith.langchain.com。
- 注册或登录账号。
- 进入 Settings。
- 找到 API Keys。
- 创建一个 API Key。
- 复制保存。
API Key 只展示一次,复制后要保存好,不要发到群里,也不要提交到代码仓库。
6. 进入 LangSmith 后先看监控相关功能
登录 LangSmith 后,页面左侧是功能导航,右侧是当前功能的具体页面。
第十天这节课先讲监控相关内容:
Tracing
Monitoring
这一节先看系统每次调用是怎么跑的、哪里慢、哪里报错。
先看左侧菜单。
| 菜单 | 中文理解 | 这一节怎么用 |
|---|---|---|
All applications |
所有应用 | 查看当前工作区里接入 LangSmith 的应用或项目入口 |
Search |
搜索 | 快速搜索 Trace、项目、Prompt 等内容 |
Home |
首页 | 回到 LangSmith 首页,看概览和入口 |
Tracing |
调用追踪 | 最常用,用来看每一次 LangChain/LangGraph 调用的 Trace |
Monitoring |
监控 | 本节重点,看整体调用量、延迟、错误趋势 |
Datasets & Experiments |
数据集与实验 | 保存测试样本,批量对比不同版本效果 |
Evaluators |
评估器 | 定义结果检查规则,本节先不展开 |
Annotation Queues |
人工标注队列 | 把回答分配给人工检查、打分、标注 |
Prompts |
Prompt 管理 | 管理 Prompt 版本,后面做 Prompt 优化时会用到 |
Playground |
调试 playground | 手动输入 Prompt 和参数,快速试模型效果 |
Studio |
可视化工作台 | 可视化搭建或调试 AI 应用流程 |
Context Hub |
上下文中心 | 管理可复用上下文、资料、工具说明等内容 |
Deployments |
部署 | 管理已部署的 agent 或服务,后面部署课再讲 |
Sandboxes |
沙箱 | 创建隔离环境做测试,不影响正式项目 |
Settings |
设置 | 管理 API Key、Workspace、账号和项目配置 |
Personal |
当前工作区 | 当前登录账号所在的个人空间 |
这节课最常用的是这几个:
Tracing
Monitoring
Settings
6.1 Tracing:看单次调用
Tracing 用来查看一次请求里面到底发生了什么。
比如用户问:
统计上个月各课程支付成功订单数量
我们可以在 Trace 里看:
问题分类有没有走 query
指标检索有没有结果
表路由选了哪些表
SQL 生成模型输入了什么上下文
SQL 生成模型输出了什么 JSON
结果分析模型有没有生成 chart
哪一步耗时最长
哪一步报错
可以把 Tracing 理解成:
看清楚“这一单请求是怎么跑的”。
6.2 Monitoring:看整体表现
Monitoring 用来看一段时间内系统整体表现。
它关注的不是某一次请求,而是整体趋势。
监控页面重点看这些指标:
| 监控指标 | 含义 |
|---|---|
| 调用量 | 最近有多少次 AI 调用 |
| 错误数 / 错误率 | 有多少调用失败,失败比例是多少 |
| 延迟 | 平均响应时间、最慢请求耗时 |
| 模型调用情况 | 哪些链路调用了模型,调用次数多少 |
| Trace 分布 | 哪些项目、哪些 run 最常出现 |
结合第十天 Text-to-SQL,可以重点看:
最近 Text-to-SQL 请求多不多
SQL 生成节点是否经常失败
表路由是否耗时很长
结果分析节点是否慢
线上是否出现大量错误 trace
可以把 Monitoring 理解成:
看清楚“系统整体运行得怎么样”。
7. 项目里怎么配置
官方最新配置方式是使用环境变量。
LangChain 官方文档里给出的核心变量包括:
export LANGSMITH_TRACING=true
export LANGSMITH_API_KEY=<your-api-key>
export LANGSMITH_PROJECT=my-project
其中:
| 环境变量 | 含义 |
|---|---|
LANGSMITH_TRACING |
是否开启 trace |
LANGSMITH_API_KEY |
LangSmith API Key |
LANGSMITH_PROJECT |
trace 归属项目名 |
LANGSMITH_ENDPOINT |
非默认区域或自部署时使用 |
LANGSMITH_WORKSPACE_ID |
一个 key 对应多个 workspace 时使用 |
官方文档说明,LangChain 应用可以通过设置 LANGSMITH_TRACING=true 和 LANGSMITH_API_KEY 开启追踪;如果要把 trace 记录到指定项目,可以设置 LANGSMITH_PROJECT。参考官方文档:Trace LangChain applications。
当前项目已经在:
YanQue-AI/.env.example
补了示例配置:
LANGSMITH_TRACING=false
LANGSMITH_API_KEY=
your_placeholder
本地使用时,把 .env.example 复制成 .env,然后改成:
LANGSMITH_TRACING=true
LANGSMITH_API_KEY=你的 LangSmith API Key
LANGSMITH_PROJECT=YanQue-AI
如果暂时不想上传 trace:
LANGSMITH_TRACING=false
8. 安装依赖
当前 YanQue-AI/uv.lock 里已经有 langsmith。
如果一个新项目还没有安装,可以用:
uv add langsmith
或者:
pip install langsmith
官方文档也给出了 pip install langsmith 和 uv add langsmith 两种方式。参考:Create an account and API key。
9. LangChain 代码怎样自动产生 Trace
对 LangChain 来说,最简单的方式是:
设置环境变量
正常运行 LangChain 代码
去 LangSmith 页面看 Trace
例如我们第七天讲过的链:
from langchain_core.output_parsers import StrOutputParser
from langchain_core.prompts import ChatPromptTemplate
from langchain_openai import ChatOpenAI
prompt = ChatPromptTemplate.from_messages(
[
("system", "你是一个耐心的 AI 助教。"),
("human", "{question}"),
]
)
model = ChatOpenAI(
model="qwen-plus",
api_key="你的百炼 API Key",
base_url="https://dashscope.aliyuncs.com/compatible-mode/v1",
temperature=0,
)
chain = prompt | model | StrOutputParser()
answer = chain.invoke({"question": "LangSmith 有什么用?"})
print(answer)
只要环境变量开启了:
LANGSMITH_TRACING=true
LANGSMITH_API_KEY=...
LANGSMITH_PROJECT=YanQue-AI
这次调用就会出现在 LangSmith 项目里。
官方文档也说明:配置环境变量后,正常运行 LangChain 代码即可记录 trace,不需要额外加很多代码。参考:Trace LangChain applications。
10. 在当前项目里能追踪哪些地方
当前项目里很多代码都用了 LangChain 或 LangGraph。
例如:
10.1 学生问答
代码位置:
YanQue-AI/src/yanque_ai/chat/service.py
里面有:
chain = prompt | self._model | StrOutputParser()
开启 LangSmith 后,可以看到:
Prompt 输入了什么
调用了哪个模型
模型返回了什么
StrOutputParser 输出了什么文本
10.2 Text-to-SQL 问题分类
代码位置:
YanQue-AI/src/yanque_ai/text_to_sql/nodes/question_classifier.py
里面有:
return prompt | model | StrOutputParser()
开启 LangSmith 后,可以看:
用户问题是什么
分类 Prompt 是什么
模型返回的 JSON 是什么
为什么分类成 query/chat/update/unclear
10.3 Text-to-SQL 表路由
代码位置:
YanQue-AI/src/yanque_ai/text_to_sql/nodes/table_router.py
可以看:
传给模型的 table_catalog 是什么
模型选择了哪些 selectedTables
reason 是什么
10.4 Text-to-SQL SQL 生成
代码位置:
YanQue-AI/src/yanque_ai/text_to_sql/nodes/sql_generation_node.py
可以看:
metric_context 是什么
table_schema_context 是什么
execution_error_feedback 是什么
模型返回 action 是什么
模型生成的 SQL 是什么
10.5 Text-to-SQL 结果分析
代码位置:
YanQue-AI/src/yanque_ai/text_to_sql/nodes/query_result_analysis_node.py
可以看:
传给模型的 columns 和 rows
模型输出的 summary
findings
basis
chart
warnings
11. 怎样给 Trace 起名字
如果所有 trace 都叫默认名字,后面不好找。
LangChain 支持通过 with_config 或调用时配置 run_name。
例如:
chain = (prompt | model | StrOutputParser()).with_config(
{
"run_name": "demo_langsmith_chain",
"tags": ["day7", "demo"],
"metadata": {"course": "yanque-ai"},
}
)
chain.invoke({"question": "LangSmith 是什么?"})
这样在 LangSmith 里更容易筛选:
run_name = demo_langsmith_chain
tags = day7, demo
metadata.course = yanque-ai
官方文档说明,LangChain 调用可以通过 config 添加 tags、metadata,也可以自定义 run name。参考:Trace LangChain applications。
12. 只追踪一小段代码
有时不想全项目都开 trace,只想临时追踪一段。
可以使用 langsmith 的 tracing context。
示例:
import langsmith as ls
with ls.tracing_context(enabled=True, project_name="yanque-ai-debug"):
chain.invoke({"question": "帮我解释一下 LangSmith"})
如果想关闭某段:
with ls.tracing_context(enabled=False):
chain.invoke({"question": "这次不记录 trace"})
官方文档说明,Python 可以用 tracing_context(enabled=True/False) 控制某段代码是否追踪。参考:Trace LangChain applications。
13. 追踪普通函数:@traceable
LangChain 链通常会自动被追踪。
但有些普通函数不是 LangChain Runnable。
例如:
整理输入
调用外部接口
解析业务结果
计算指标
这时可以使用 @traceable。
示例:
from langsmith import traceable
@traceable(name="normalize_question")
def normalize_question(question: str) -> str:
return question.strip()
@traceable(name="build_answer")
def build_answer(question: str) -> str:
normalized = normalize_question(question)
return f"用户问题是:{normalized}"
print(build_answer(" LangSmith 是什么? "))
LangSmith 里会看到:
build_answer
└─ normalize_question
官方文档说明,@traceable 适合直接给函数加追踪,并且会自动处理嵌套调用的上下文关系。参考:Custom instrumentation。
14. 怎么看一条 Trace
打开 LangSmith 后,不要只看最终回答。
建议按这个顺序看:
14.1 看输入
先看用户问题有没有传对。
例如:
question = "统计上个月支付成功订单数量"
如果输入本身就错了,后面都不用看。
14.2 看 Prompt
重点看:
系统提示词有没有生效
变量有没有填进去
历史消息有没有太长
知识库内容有没有拼进去
工具结果有没有拼进去
14.3 看模型输出
模型输出是否符合预期。
比如 Text-to-SQL 分类节点应该返回:
{
"questionType": "query",
"routeReason": "用户希望统计业务数据。"
}
如果模型返回了普通文本,说明 Prompt 约束或解析逻辑有问题。
14.4 看耗时
哪一步最慢?
是模型慢?
是知识库检索慢?
是工具接口慢?
还是结果分析慢?
LangSmith 可以帮助我们定位瓶颈。
14.5 看错误
如果某一步报错,Trace 里可以看到错误发生在哪个子步骤。
这比只看最终接口失败更清楚。
15. 和我们项目的 Text-to-SQL 怎么结合
以 Text-to-SQL 为例。
如果用户问:
统计上个月各课程支付成功订单数量
但系统没有生成 SQL。
没有 LangSmith 时,你可能只能看到:
finalAnswer = 当前缺少可用的表结构、字段或关联关系,需要补充元数据后才能生成 SQL。
有 LangSmith 后,可以看每一步:
问题分类是不是 query
指标检索有没有结果
表目录有没有传给模型
表路由选中了哪些表
表详情上下文有没有字段
SQL 生成节点返回的是 generate_sql 还是 need_schema
模型给出的 reason 是什么
这样排查就不是猜,而是沿 Trace 一步一步看。
16. 使用时要注意什么
LangSmith 很有用,但也要注意数据安全。
16.1 不要上传敏感信息
Trace 里可能包含:
用户问题
Prompt
模型输入输出
工具结果
检索内容
SQL
业务字段
如果里面有手机号、姓名、身份证号、订单号等敏感数据,要谨慎。
上课演示和本地开发尽量使用测试数据。
生产环境要结合脱敏、权限和公司规范。
16.2 API Key 不要提交到 Git
不要把这个写进代码:
LANGSMITH_API_KEY = "your_placeholder"
应该放在:
.env
服务器环境变量
密钥管理系统
16.3 不想记录时关掉 tracing
本地不需要时:
LANGSMITH_TRACING=false
或者临时不设置 LANGSMITH_API_KEY。
17. 常见问题
17.1 我打开了代码,为什么 LangSmith 没有 trace?
检查:
LANGSMITH_TRACING 是否为 true
LANGSMITH_API_KEY 是否填写
是否运行了真正的 LangChain/LangGraph 调用
项目名 LANGSMITH_PROJECT 是否看错
网络是否能访问 LangSmith
17.2 为什么 trace 在 default 项目里?
没有设置:
LANGSMITH_PROJECT
就可能进入默认项目。
设置:
LANGSMITH_PROJECT=YanQue-AI
17.3 LangSmith 会让模型变聪明吗?
不会。
它不改变模型能力。
它帮助我们看清楚模型调用过程,从而更快调试 Prompt、工具、检索和流程。
17.4 LangSmith 和日志有什么区别?
日志通常是一行一行的文本。
LangSmith 是结构化的调用树。
它更适合看 AI 链路:
Prompt -> Model -> Parser -> Tool -> Model
18. 本节小结
LangSmith 解决的问题是:
AI 应用运行时,我们怎么知道每一步到底发生了什么?
它可以帮助我们查看:
Prompt
模型输入输出
工具调用
检索结果
耗时
错误
子步骤
在当前项目里,它最适合配合:
LangChain 学生问答
LangGraph 流程调试
Text-to-SQL 分类、选表、SQL 生成、结果分析
最后记住三句话:
- LangChain 负责组织 AI 调用。
- LangGraph 负责编排复杂流程。
- LangSmith 负责观察、调试和复盘这些调用过程。
参考资料:







