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_metricneed_schemaneed_clarification 等分支。


1. 今天学完要会什么

学完这一节,应该能回答下面几个问题:

  1. Text-to-SQL 为什么不能直接让大模型连数据库?
  2. 用户在前端提问后,请求经过了哪些代码文件?
  3. LangGraph 在这个功能里负责什么?
  4. 系统为什么要先做问题分类?
  5. 指标检索、表路由、表详情加载分别解决什么问题?
  6. SQL 生成后为什么还要校验?
  7. Python 为什么不直接查询 MySQL,而是调用 Java 内部接口?
  8. 查询结果是怎样变成摘要、发现、依据和图表的?

这一节课的重点不是背代码,而是理解一条完整链路。


2. 先看最终效果

后台有一个“数据分析”页面。

用户可以输入:

统计上个月各课程支付成功订单数量,并按课程对比

系统会尝试做这些事:

识别这是一个数据查询问题
找到相关指标口径
找到可能相关的数据表
读取表字段详情
让大模型生成只读 SELECT SQL
校验 SQL 是否安全
调用 Java 内部接口执行 SQL
拿到查询结果
生成分析摘要和图表数据
前端展示结果

整体流程如下:

Text-to-SQL 项目落地主流程

注意,这不是:

用户问题 -> 大模型随便写 SQL -> 直接查数据库

真实项目里这样做很危险。

更安全的方式是:

模型负责理解和生成
程序负责校验和执行边界

3. 项目代码分成哪几层

先不要急着看每个函数。

我们先从整体结构看。

当前代码架构图

当前代码分成四层:

可以这样理解 主要代码
前端页面 用户输入问题、查看结果 YanQue-Admin-Web/src/pages/TextToSqlPage.tsx
Java 管理端 接收前端请求,转发给 Python TextToSqlController.javaPythonTextToSqlClient.java
Python AI 服务 用 LangGraph 编排 AI 流程 graph.pyroute_nodes.py、各个 node
Java 内部执行接口 真正执行只读 SQL InternalTextToSqlController.javaTextToSqlQueryServiceImpl.java

这几个模块的分工很清楚:

模块 做什么 不做什么
前端 提问、展示结果 不生成 SQL
Java 对外接口 接收后台请求 不调用大模型
Python 调模型、跑流程、分析结果 不直接连业务数据库
Java 内部接口 校验 SQL、查数据库 不负责理解自然语言

这里有一个非常重要的工程思想:

Python 负责 AI 编排,Java 负责业务数据访问。


4. 一次请求的完整路线

用户点“开始分析”以后,请求会经过下面这条路线:

Java 和 Python 调用时序图

用代码文件串起来就是:

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 问题类型,比如 querychatupdateunclear
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_typeroute_reason
指标检索 metric_hitsmetric_context
表路由 selected_tablestable_selection_reason
表详情 selected_table_detailsselected_table_schema_context
SQL 生成 generated_sqlsql_actionused_tablesused_fields
SQL 执行 query_columnsquery_rowsexecuted_sql
结果分析 query_result_analysisanalysis_summary
追问补充 clarification_historyclarification_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
]

也就是说,模型返回一个不存在的表名,也不会进入后续流程。

这一步的图示:

Router 选表和详细 Schema


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
字段 statuspay_success_timeorder_amount
关联关系 order_payment.product_id -> order_product.id
查询规则 blockedFieldsmaskedFieldsmaxRows
索引 uk_order_noidx_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 开头 只允许查询
不允许分号 防止多语句
不允许注释 防止绕过
不允许危险关键字 拦截 INSERTUPDATEDELETEDROP
表名必须在选中表详情里 防止模型编表
字段必须在表详情里 防止模型编字段
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 再做一层校验:

SQL Guard 和执行流程

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 idproduct_idstatuspay_success_timecreated_at
order_product idcourse_contentteaching_modeprice

所以不能写:

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 观察和调试这些链路到底怎么跑

图示如下:

LangSmith 在 AI 应用里的位置


2. 为什么项目里需要 LangSmith

普通后端接口出错时,我们可以看:

请求参数
SQL
异常堆栈
日志

但是 AI 应用出错时,问题经常不是一个异常,而是某一步“看起来能跑,但结果不对”。

例如:

用户问:今天有什么课?
AI 回答:没有课。

原因可能有很多:

可能原因 需要看什么
工具没有被调用 模型是否选择了工具
工具参数错了 工具调用参数是什么
Java 工具接口没返回数据 工具返回结果是什么
Prompt 没把工具结果交给模型 最终 Prompt 是什么
模型理解错了 模型输入和输出是什么

LangSmith 可以把这些步骤展示成一棵 Trace 树。


3. 什么是 Trace

Trace 可以理解成:

一次完整 AI 调用的运行记录。

一次 Trace 里可以包含很多子步骤。

例如一次学生问答可能有:

Root Trace:学生提问
  ├─ Prompt:组装系统提示词和历史消息
  ├─ LLM:判断是否需要工具
  ├─ Tool:调用 Java 查课表
  ├─ LLM:根据工具结果生成最终回答
  └─ Parser:整理输出

图示如下:

LangSmith Trace 树结构

这里要记住:

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

步骤:

  1. 打开 smith.langchain.com
  2. 注册或登录账号。
  3. 进入 Settings。
  4. 找到 API Keys。
  5. 创建一个 API Key。
  6. 复制保存。

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=trueLANGSMITH_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 langsmithuv 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 生成、结果分析

最后记住三句话:

  1. LangChain 负责组织 AI 调用。
  2. LangGraph 负责编排复杂流程。
  3. LangSmith 负责观察、调试和复盘这些调用过程。

参考资料: