主题
构建 SQL 智能体
概述
在本教程中,你将学习如何使用 LangChain 智能体构建一个能够回答 SQL 数据库相关问题的智能体。
从高层来看,该智能体将:
- 从数据库获取可用的表和 schema
- 判断哪些表与问题相关
- 获取相关表的 schema
- 根据问题及 schema 中的信息生成查询
- 使用 LLM 复查查询中的常见错误
- 执行查询并返回结果
- 修正数据库引擎暴露的错误,直到查询成功
- 根据结果构思回复
WARNING
构建 SQL 数据库的 Q&A 系统需要执行模型生成的 SQL 查询。这样做存在固有风险。请确保你的数据库连接权限始终按照智能体的需求尽可能缩小范围。这将缓解(但无法消除)构建模型驱动系统的风险。
概念
以下教程涵盖以下概念:
设置
Install dependencies
bash
pip install langchain langgraphSet up LangSmith
设置 LangSmith 以检查链或智能体内部正在发生的事情。然后设置以下环境变量:
shell
export LANGSMITH_TRACING="true"
export LANGSMITH_API_KEY="..."构建你的 SQL 智能体
Select an LLM
选择一个支持工具调用的模型:
👉 阅读 OpenAI 聊天模型集成文档
bash
pip install -U "langchain[openai]"bash
uv add "langchain[openai]"python
import os
from langchain.chat_models import init_chat_model
os.environ["OPENAI_API_KEY"] = "sk-..."
model = init_chat_model("gpt-5.5")python
import os
from langchain_openai import ChatOpenAI
os.environ["OPENAI_API_KEY"] = "sk-..."
model = ChatOpenAI(model="gpt-5.5")下面示例中展示的输出使用了 OpenAI。
Configure the database
你将为本教程创建一个 SQLite 数据库。SQLite 是一个轻量级数据库,易于设置和使用。我们将加载 chinook 数据库,这是一个模拟数字媒体商店的示例数据库。
为方便起见,我们已将数据库(Chinook.db)托管在公共 GCS 存储桶上。
python
import pathlib
import requests
url = "https://storage.googleapis.com/benchmarks-artifacts/chinook/Chinook.db"
local_path = pathlib.Path("Chinook.db")
if local_path.exists():
print(f"{local_path} already exists, skipping download.")
else:
response = requests.get(url, timeout=60)
if response.status_code == 200:
local_path.write_bytes(response.content)
print(f"File downloaded and saved as {local_path}")
else:
print(f"Failed to download the file. Status code: {response.status_code}")我们将使用 Python 内置的 sqlite3 模块与数据库交互:
python
import sqlite3
con = sqlite3.connect("Chinook.db")
cursor = con.cursor()
cursor.execute("SELECT name FROM sqlite_master WHERE type='table';")
tables = [row[0] for row in cursor.fetchall() if not row[0].startswith("sqlite_")]
print("Dialect: sqlite")
print(f"Available tables: {tables}")
cursor.execute("SELECT * FROM Artist LIMIT 5;")
print(f"Sample output: {cursor.fetchall()}")
con.close()Dialect: sqlite
Available tables: ['Album', 'Artist', 'Customer', 'Employee', 'Genre', 'Invoice', 'InvoiceLine', 'MediaType', 'Playlist', 'PlaylistTrack', 'Track']
Sample output: [(1, 'AC/DC'), (2, 'Accept'), (3, 'Aerosmith'), (4, 'Alanis Morissette'), (5, 'Alice In Chains')]Add tools for database interactions
WARNING
以下数据库工具只是用于演示目的的最小封装。它们并非以安全为目标,也不适合在生产中使用。请使用范围尽可能窄的数据库权限,并在执行模型生成的 SQL 之前添加应用专属的校验。
我们可以使用 langchain.tools 中的 @tool 装饰器,将数据库工具实现为轻量封装:
python
import sqlite3
from langchain.tools import tool
# Below are minimal tools for demonstration purposes.
# They are not intended to be secure or for production use.
@tool
def sql_db_list_tables() -> str:
"""Input is an empty string, output is a comma-separated list of tables in the database."""
con = sqlite3.connect("Chinook.db")
try:
cursor = con.cursor()
cursor.execute("SELECT name FROM sqlite_master WHERE type='table';")
tables = [row[0] for row in cursor.fetchall() if not row[0].startswith("sqlite_")]
return ", ".join(tables)
finally:
con.close()
@tool
def sql_db_schema(table_names: str) -> str:
"""Input to this tool is a comma-separated list of tables, output is the schema and sample rows for those tables.
Be sure that the tables actually exist by calling sql_db_list_tables first!
Example Input: table1, table2, table3"""
con = sqlite3.connect("Chinook.db")
try:
cursor = con.cursor()
cursor.execute("SELECT name FROM sqlite_master WHERE type='table';")
valid_tables = {row[0] for row in cursor.fetchall() if not row[0].startswith("sqlite_")}
results = []
for table in table_names.split(","):
table = table.strip()
if table not in valid_tables:
results.append(f"Error: table_names {{{table!r}}} not found in database")
continue
cursor.execute("SELECT sql FROM sqlite_master WHERE type='table' AND name=?;", (table,))
schema_row = cursor.fetchone()
if schema_row:
results.append(schema_row[0])
try:
quoted_table = '"' + table.replace('"', '""') + '"'
cursor.execute(f"SELECT * FROM {quoted_table} LIMIT 3;")
rows = cursor.fetchall()
if rows:
col_names = [description[0] for description in cursor.description]
results.append(
f"/*\n3 rows from {table} table:\n"
+ "\t".join(col_names)
+ "\n"
+ "\n".join("\t".join(str(x) for x in row) for row in rows)
+ "\n*/"
)
except Exception as e:
results.append(f"Error fetching sample rows: {e}")
return "\n\n".join(results)
finally:
con.close()
@tool
def sql_db_query(query: str) -> str:
"""Input to this tool is a detailed and correct SQL query, output is a result from the database.
If the query is not correct, an error message will be returned.
If an error is returned, rewrite the query, check the query, and try again.
If you encounter an issue with Unknown column 'xxxx' in 'field list', use sql_db_schema to query the correct table fields."""
con = sqlite3.connect("Chinook.db")
try:
cursor = con.cursor()
cursor.execute(query)
res = cursor.fetchall()
return str(res)
except Exception as e:
return f"Error: {e}"
finally:
con.close()
@tool
def sql_db_query_checker(query: str) -> str:
"""Use this tool to double check if your query is correct before executing it.
Always use this tool before executing a query with sql_db_query!"""
trigger_prompt = """{query}
Double check the sqlite query above for common mistakes, including:
- Using NOT IN with NULL values
- Using UNION when UNION ALL should have been used
- Using BETWEEN for exclusive ranges
- Data type mismatch in predicates
- Properly quoting identifiers
- Using the correct number of arguments for functions
- Casting to the correct data type
- Using the proper columns for joins
If there are any of the above mistakes, rewrite the query. If there are no mistakes, just reproduce the original query.
Output the final SQL query only.
SQL Query: """.format(query=query)
response = model.invoke(trigger_prompt)
return response.text.strip()
tools = [sql_db_list_tables, sql_db_schema, sql_db_query, sql_db_query_checker]
# Use a distinct loop variable so it does not shadow the `tool` decorator.
for t in tools:
print(f"{t.name}: {t.description}\n")sql_db_query: Input to this tool is a detailed and correct SQL query, output is a result from the database.
If the query is not correct, an error message will be returned.
If an error is returned, rewrite the query, check the query, and try again.
If you encounter an issue with Unknown column 'xxxx' in 'field list', use sql_db_schema to query the correct table fields.
sql_db_schema: Input to this tool is a comma-separated list of tables, output is the schema and sample rows for those tables.
Be sure that the tables actually exist by calling sql_db_list_tables first!
Example Input: table1, table2, table3
sql_db_list_tables: Input is an empty string, output is a comma-separated list of tables in the database.
sql_db_query_checker: Use this tool to double check if your query is correct before executing it.
Always use this tool before executing a query with sql_db_query!Create the agent
使用 create_agent 以极少的代码构建一个 ReAct 智能体。该智能体会解析请求并生成 SQL 命令,由工具执行。如果命令出错,错误消息会返回给模型。模型随后可以查看原始请求和新的错误消息,并生成新的命令。这一过程会持续进行,直到 LLM 成功生成命令或达到结束次数。这种向模型提供反馈(此处为错误消息)的模式非常强大。
使用具有描述性的系统提示词初始化智能体,以自定义其行为:
python
system_prompt = """
You are an agent designed to interact with a SQL database.
Given an input question, create a syntactically correct {dialect} query to run,
then look at the results of the query and return the answer. Unless the user
specifies a specific number of examples they wish to obtain, always limit your
query to at most {top_k} results.
You can order the results by a relevant column to return the most interesting
examples in the database. Never query for all the columns from a specific table,
only ask for the relevant columns given the question.
You MUST double check your query before executing it. If you get an error while
executing a query, rewrite the query and try again.
DO NOT make any DML statements (INSERT, UPDATE, DELETE, DROP etc.) to the
database.
To start you should ALWAYS look at the tables in the database to see what you
can query. Do NOT skip this step.
Then you should query the schema of the most relevant tables.
""".format(
dialect="sqlite",
top_k=5,
)现在,使用模型、工具和提示词创建一个智能体:
python
from langchain.agents import create_agent
agent = create_agent(
model,
tools,
system_prompt=system_prompt,
)Run the agent
在一个示例查询上运行智能体,并观察其行为:
python
question = "Which genre on average has the longest tracks?"
stream = agent.stream_events(
{"messages": [{"role": "user", "content": question}]},
version="v3",
)
for kind, item in stream.interleave("messages", "tool_calls"):
if kind == "messages":
for token in item.text:
print(token, end="", flush=True)
elif kind == "tool_calls":
print(f"\nTool call: {item.tool_name}({item.input})")
for delta in item.output_deltas:
print(delta, end="", flush=True)
print(f"\nTool result: {item.output}")
final_state = stream.output================================ Human Message =================================
Which genre on average has the longest tracks?
================================== Ai Message ==================================
Tool Calls:
sql_db_list_tables (call_BQsWg8P65apHc8BTJ1NPDvnM)
Call ID: call_BQsWg8P65apHc8BTJ1NPDvnM
Args:
================================= Tool Message =================================
Name: sql_db_list_tables
Album, Artist, Customer, Employee, Genre, Invoice, InvoiceLine, MediaType, Playlist, PlaylistTrack, Track
================================== Ai Message ==================================
Tool Calls:
sql_db_schema (call_i89tjKECFSeERbuACYm4w0cU)
Call ID: call_i89tjKECFSeERbuACYm4w0cU
Args:
table_names: Track, Genre
================================= Tool Message =================================
Name: sql_db_schema
CREATE TABLE "Genre" (
"GenreId" INTEGER NOT NULL,
"Name" NVARCHAR(120),
PRIMARY KEY ("GenreId")
)
/*
3 rows from Genre table:
GenreId Name
1 Rock
2 Jazz
3 Metal
*/
CREATE TABLE "Track" (
"TrackId" INTEGER NOT NULL,
"Name" NVARCHAR(200) NOT NULL,
"AlbumId" INTEGER,
"MediaTypeId" INTEGER NOT NULL,
"GenreId" INTEGER,
"Composer" NVARCHAR(220),
"Milliseconds" INTEGER NOT NULL,
"Bytes" INTEGER,
"UnitPrice" NUMERIC(10, 2) NOT NULL,
PRIMARY KEY ("TrackId"),
FOREIGN KEY("MediaTypeId") REFERENCES "MediaType" ("MediaTypeId"),
FOREIGN KEY("GenreId") REFERENCES "Genre" ("GenreId"),
FOREIGN KEY("AlbumId") REFERENCES "Album" ("AlbumId")
)
/*
3 rows from Track table:
TrackId Name AlbumId MediaTypeId GenreId Composer Milliseconds Bytes UnitPrice
1 For Those About To Rock (We Salute You) 1 1 1 Angus Young, Malcolm Young, Brian Johnson 343719 11170334 0.99
2 Balls to the Wall 2 2 1 U. Dirkschneider, W. Hoffmann, H. Frank, P. Baltes, S. Kaufmann, G. Hoffmann 342562 5510424 0.99
3 Fast As a Shark 3 2 1 F. Baltes, S. Kaufman, U. Dirkscneider & W. Hoffman 230619 3990994 0.99
*/
================================== Ai Message ==================================
Tool Calls:
sql_db_query_checker (call_G64yYm6R6UauiVPCXJZMA49b)
Call ID: call_G64yYm6R6UauiVPCXJZMA49b
Args:
query: SELECT Genre.Name, AVG(Track.Milliseconds) AS AverageLength FROM Track INNER JOIN Genre ON Track.GenreId = Genre.GenreId GROUP BY Genre.Name ORDER BY AverageLength DESC LIMIT 5;
================================= Tool Message =================================
Name: sql_db_query_checker
SELECT Genre.Name, AVG(Track.Milliseconds) AS AverageLength FROM Track INNER JOIN Genre ON Track.GenreId = Genre.GenreId GROUP BY Genre.Name ORDER BY AverageLength DESC LIMIT 5;
================================== Ai Message ==================================
Tool Calls:
sql_db_query (call_AnO3SrhD0ODJBxh6dHMwvHwZ)
Call ID: call_AnO3SrhD0ODJBxh6dHMwvHwZ
Args:
query: SELECT Genre.Name, AVG(Track.Milliseconds) AS AverageLength FROM Track INNER JOIN Genre ON Track.GenreId = Genre.GenreId GROUP BY Genre.Name ORDER BY AverageLength DESC LIMIT 5;
================================= Tool Message =================================
Name: sql_db_query
[('Sci Fi & Fantasy', 2911783.0384615385), ('Science Fiction', 2625549.076923077), ('Drama', 2575283.78125), ('TV Shows', 2145041.0215053763), ('Comedy', 1585263.705882353)]
================================== Ai Message ==================================
On average, the genre with the longest tracks is "Sci Fi & Fantasy" with an average track length of approximately 2,911,783 milliseconds. This is followed by "Science Fiction," "Drama," "TV Shows," and "Comedy."该智能体正确地编写了查询、检查了查询并运行它,从而为其最终回复提供依据。
NOTE
你可以在 LangSmith 追踪中查看上述运行的各个方面,包括执行的步骤、调用的工具、LLM 看到的提示词等。
(Optional) Use Studio
Studio 提供了「客户端」循环以及记忆,因此你可以将其作为聊天界面运行并查询数据库。你可以提出诸如「告诉我数据库的结构」或「显示排名前 5 的客户的发票」之类的问题。你将看到生成的 SQL 命令及其输出结果。如何开始使用的细节见下文。
Run your agent in Studio
除了前面提到的包之外,你还需要:
shell
pip install -U langgraph-cli[inmem]>=0.4.0在你将要运行的目录中,需要一个包含以下内容的 langgraph.json 文件:
json
{
"dependencies": ["."],
"graphs": {
"agent": "./sql_agent.py:agent",
"graph": "./sql_agent_langgraph.py:graph"
},
"env": ".env"
}创建一个 sql_agent.py 文件,并插入以下内容:
python
# sql_agent.py for studio
import pathlib
import sqlite3
import requests
from langchain.agents import create_agent
from langchain.chat_models import init_chat_model
from langchain.tools import tool
# Initialize an LLM
model = init_chat_model("gpt-5.5")
# Get the database, store it locally
url = "https://storage.googleapis.com/benchmarks-artifacts/chinook/Chinook.db"
local_path = pathlib.Path("Chinook.db")
if local_path.exists():
print(f"{local_path} already exists, skipping download.")
else:
response = requests.get(url, timeout=60)
if response.status_code == 200:
local_path.write_bytes(response.content)
print(f"File downloaded and saved as {local_path}")
else:
print(f"Failed to download the file. Status code: {response.status_code}")
# Below are minimal tools for demonstration purposes.
@tool
def sql_db_list_tables() -> str:
"""Input is an empty string, output is a comma-separated list of tables in the database."""
con = sqlite3.connect("Chinook.db")
try:
cursor = con.cursor()
cursor.execute("SELECT name FROM sqlite_master WHERE type='table';")
tables = [row[0] for row in cursor.fetchall() if not row[0].startswith("sqlite_")]
return ", ".join(tables)
finally:
con.close()
@tool
def sql_db_schema(table_names: str) -> str:
"""Input to this tool is a comma-separated list of tables, output is the schema and sample rows for those tables.
Be sure that the tables actually exist by calling sql_db_list_tables first!
Example Input: table1, table2, table3"""
con = sqlite3.connect("Chinook.db")
try:
cursor = con.cursor()
cursor.execute("SELECT name FROM sqlite_master WHERE type='table';")
valid_tables = {row[0] for row in cursor.fetchall() if not row[0].startswith("sqlite_")}
results = []
for table in table_names.split(","):
table = table.strip()
if table not in valid_tables:
results.append(f"Error: table_names {{{table!r}}} not found in database")
continue
cursor.execute("SELECT sql FROM sqlite_master WHERE type='table' AND name=?;", (table,))
schema_row = cursor.fetchone()
if schema_row:
results.append(schema_row[0])
try:
quoted_table = '"' + table.replace('"', '""') + '"'
cursor.execute(f"SELECT * FROM {quoted_table} LIMIT 3;")
rows = cursor.fetchall()
if rows:
col_names = [description[0] for description in cursor.description]
results.append(
f"/*\n3 rows from {table} table:\n"
+ "\t".join(col_names)
+ "\n"
+ "\n".join("\t".join(str(x) for x in row) for row in rows)
+ "\n*/"
)
except Exception as e:
results.append(f"Error fetching sample rows: {e}")
return "\n\n".join(results)
finally:
con.close()
@tool
def sql_db_query(query: str) -> str:
"""Input to this tool is a detailed and correct SQL query, output is a result from the database.
If the query is not correct, an error message will be returned.
If an error is returned, rewrite the query, check the query, and try again.
If you encounter an issue with Unknown column 'xxxx' in 'field list', use sql_db_schema to query the correct table fields."""
con = sqlite3.connect("Chinook.db")
try:
cursor = con.cursor()
cursor.execute(query)
res = cursor.fetchall()
return str(res)
except Exception as e:
return f"Error: {e}"
finally:
con.close()
@tool
def sql_db_query_checker(query: str) -> str:
"""Use this tool to double check if your query is correct before executing it.
Always use this tool before executing a query with sql_db_query!"""
trigger_prompt = """{query}
Double check the sqlite query above for common mistakes, including:
- Using NOT IN with NULL values
- Using UNION when UNION ALL should have been used
- Using BETWEEN for exclusive ranges
- Data type mismatch in predicates
- Properly quoting identifiers
- Using the correct number of arguments for functions
- Casting to the correct data type
- Using the proper columns for joins
If there are any of the above mistakes, rewrite the query. If there are no mistakes, just reproduce the original query.
Output the final SQL query only.
SQL Query: """.format(query=query)
response = model.invoke(trigger_prompt)
return response.text.strip()
tools = [sql_db_list_tables, sql_db_schema, sql_db_query, sql_db_query_checker]
# Use a distinct loop variable so it does not shadow the `tool` decorator.
for t in tools:
print(f"{t.name}: {t.description}\n")
# Use create_agent
system_prompt = """
You are an agent designed to interact with a SQL database.
Given an input question, create a syntactically correct {dialect} query to run,
then look at the results of the query and return the answer. Unless the user
specifies a specific number of examples they wish to obtain, always limit your
query to at most {top_k} results.
You can order the results by a relevant column to return the most interesting
examples in the database. Never query for all the columns from a specific table,
only ask for the relevant columns given the question.
You MUST double check your query before executing it. If you get an error while
executing a query, rewrite the query and try again.
DO NOT make any DML statements (INSERT, UPDATE, DELETE, DROP etc.) to the
database.
To start you should ALWAYS look at the tables in the database to see what you
can query. Do NOT skip this step.
Then you should query the schema of the most relevant tables.
""".format(
dialect="sqlite",
top_k=5,
)
agent = create_agent(
model,
tools,
system_prompt=system_prompt,
)Implement human-in-the-loop review
在执行 SQL 查询之前检查其是否有意外操作或低效之处,可能是明智之举。
LangChain 智能体支持内置的人在回路中间件,可为智能体工具调用添加监督。让我们将智能体配置为在调用 sql_db_query 工具时暂停以进行人工审核:
python
from langchain.agents import create_agent
from langchain.agents.middleware import HumanInTheLoopMiddleware
from langgraph.checkpoint.memory import InMemorySaver
agent = create_agent(
model,
tools,
system_prompt=system_prompt,
middleware=[
HumanInTheLoopMiddleware(
interrupt_on={"sql_db_query": True},
description_prefix="Tool execution pending approval",
),
],
checkpointer=InMemorySaver(),
)运行智能体时,它现在会在执行 sql_db_query 工具之前暂停以进行审核:
python
question = "Which genre on average has the longest tracks?"
config = {"configurable": {"thread_id": "1"}}
stream = agent.stream_events(
{"messages": [{"role": "user", "content": question}]},
config,
version="v3",
)
for kind, item in stream.interleave("messages", "tool_calls"):
if kind == "messages":
for token in item.text:
print(token, end="", flush=True)
elif kind == "tool_calls":
print(f"\nTool call: {item.tool_name}({item.input})")
if stream.interrupted:
print("INTERRUPTED:")
interrupt = stream.interrupts[0]
for request in interrupt.value["action_requests"]:
print(request["description"]) ...
INTERRUPTED:
Tool execution pending approval
Tool: sql_db_query
Args: {'query': 'SELECT g.Name AS Genre, AVG(t.Milliseconds) AS AvgTrackLength FROM Track t JOIN Genre g ON t.GenreId = g.GenreId GROUP BY g.Name ORDER BY AvgTrackLength DESC LIMIT 1;'}我们可以恢复执行,在本例中即接受该查询,使用 Command:
python
from langgraph.types import Command
stream = agent.stream_events(
Command(resume={"decisions": [{"type": "approve"}]}),
config,
version="v3",
)
for kind, item in stream.interleave("messages", "tool_calls"):
if kind == "messages":
for token in item.text:
print(token, end="", flush=True)
elif kind == "tool_calls":
print(f"\nTool call: {item.tool_name}({item.input})")
if stream.interrupted:
print("INTERRUPTED:")
interrupt = stream.interrupts[0]
for request in interrupt.value["action_requests"]:
print(request["description"])================================== Ai Message ==================================
Tool Calls:
sql_db_query (call_7oz86Epg7lYRqi9rQHbZPS1U)
Call ID: call_7oz86Epg7lYRqi9rQHbZPS1U
Args:
query: SELECT Genre.Name, AVG(Track.Milliseconds) AS AvgDuration FROM Track JOIN Genre ON Track.GenreId = Genre.GenreId GROUP BY Genre.Name ORDER BY AvgDuration DESC LIMIT 5;
================================= Tool Message =================================
Name: sql_db_query
[('Sci Fi & Fantasy', 2911783.0384615385), ('Science Fiction', 2625549.076923077), ('Drama', 2575283.78125), ('TV Shows', 2145041.0215053763), ('Comedy', 1585263.705882353)]
================================== Ai Message ==================================
The genre with the longest average track length is "Sci Fi & Fantasy" with an average duration of about 2,911,783 milliseconds, followed by "Science Fiction" and "Drama."详情请参阅人在回路指南。
后续步骤
如需更深入的自定义,请查看本教程,了解如何直接使用 LangGraph 原语实现 SQL 智能体。