使用 Gemini Agent 在 SQL 工具与网络搜索之间传递查询内容
Vertex AI 上的 LangChain 工具调用代理如何在三种 SQLite 文本转 SQL 工具与实时网络搜索之间进行选择,以及可能出现的数据、依赖关系和认证方面的问题。
来源: Building a Multi-Tool AI Agent for Bangladesh: Teaching Gemini to Pick the Right Tool for the Job
基于单一数据源构建的聊天机器人只能很好地处理某一类问题,一旦用户询问相关但不同的内容,它要么编造答案,要么直接放弃。更实用的工程目标则是打造一个能够识别自身知识局限,并将每个问题转交给真正能解答它的组件的系统。本指南正是要实现这一目标:通过为每个问题从四种专用工具中选择一种,让同一个智能体能够回答有关孟加拉国的各类问题(学校、医院、餐厅以及通用知识等)。在讲解过程中,您将了解如何将SQLite数据库封装为安全的文本转SQL工具,为何工具描述比路由代码更为重要,以及哪些非模型故障最有可能阻碍此类项目的进展。
为何协调器优于无所不知的单一模型
如今大多数先进的AI系统都遵循相同的架构:一个擅长理解用户意图的语言模型,负责协调多个专注执行特定任务、且可靠性高的工具来获取事实信息。服务助手会在计费系统与知识库之间切换;研究助手则会在网络搜索与内部文档之间切换。该模型无需存储答案,只需判断应使用哪种工具来解决问题。
本项目采用同样的模式,由四名专业人员与一名协调员共同协作:
┌──────────────────────────┐
question ──────────▶ │ AgentExecutor │
│ (Gemini + tool-calling) │
└────────────┬──────────────┘
│ picks 1+ tools based on
│ their description
┌──────────────┬───────┴───────┬───────────────┐
▼ ▼ ▼ ▼
InstitutionsDBTool HospitalsDBTool RestaurantsDBTool WebSearchTool
│ │ │ │
institutions.db hospitals.db restaurants.db Tavily API
(SQLite) (SQLite) (SQLite) (live web)
InstitutionsDBTool用于处理来自官方EIIN注册名录的各类学校、学院及宗教学校。HospitalsDBTool用于处理在DGHS注册的政府卫生机构信息。RestaurantsDBTool用于处理从Google Places获取数据的各类餐厅信息。
WebSearchTool会针对数据库无法回答的问题通过Tavily API进行实时搜索。所有功能均基于LangChain的AgentExecutor运行,Google Vertex AI上的Gemini则充当推理引擎。每个数据库工具会将自然语言问题转换为SQL语句并执行,再将查询结果转换回可读的句子。
以下是一些示例性的路由决策,用以明确问题意图:
- “拉杰沙希专区有多少所学院?”这类问题会交给
InstitutionsDBTool处理,因为这是基于EIIN数据进行的结构化统计。 - “列出恰图戈尔区的政府医院”这类问题则交由
HospitalsDBTool处理,因为这是基于DGHS设施信息生成的结构化列表。
RestaurantsDBTool处理,因为这需要在餐厅数据库中查询。WebSearchTool处理,因为没有数据库存储这类通用知识。该系统从不直接查看数据库内容,它所看到的只是四段用通俗英语写成的工具描述,而请求路由的质量取决于这些描述的准确性,而非任何预设的规则分支。
在设计任何内容之前先检查数据集
在有任何代理代码出现之前,人们先从Hugging Face下载了这三个数据集,并逐行进行检查。那一个小时的努力比其他任何步骤都更有价值,因为项目需求说明书中对数据内容的描述其实是错误的。它期望医院数据中包含床位数量,而机构数据中则应包含大学学位项目信息。实际情况却并非如此:
all-bangladeshi-hospitals是一个列有DGHS/MOPA注册的医疗机构及办事处的目录,其中包含名称、机构类型、所属机构、部门、所在地区以及是否为私营或政府的标识,但不包含床位数量或员工人数等信息。Institutional-Information-of-Bangladesh涵盖那些拥有EIIN注册号的学校、学院、宗教学校和技术院校,其中不包含大学信息或任何学位项目的相关数据。
Bangladeshi-Restaurant-Data 数据来自对 Google Places 的抓取:包括名称、地址、坐标、评分以及评论数量,没有菜品类型这一列。描述与实际内容之间存在差异是很常见的现象。正确的做法不是强行让数据符合简报要求,而应是依据实际存在的信息进行设计,并将这种差异记录下来,避免日后有人误将其视为漏洞。在本案中,“biryani”这一查询通过餐厅名称的文本匹配实现,而位置筛选则通过地址的文本匹配实现,因为系统中既没有菜品类型字段也没有区域字段。这种折中方案影响很小,并且有明确的文档记载。
有一个更重要的习惯值得尽早养成:将数据集视为真实来源,而任何架构图或简报都只是某人的猜测。.head() 输出的内容才是真正的规范。
将格式混乱的 CSV 文件加载到结构化的 SQLite 表中
每个数据集都会经过清洗、转换为合适的类型,然后写入各自的 SQLite 文件中。架构会被明确声明,这样 SQLite 就不会自行推断列类型,后续的文本转 SQL 过程也能使用有意义的列名。请注意 name 字段上的 NOT NULL 约束,以及默认值为零的整数型 is_private 标志:
HOSPITALS_SCHEMA = """
CREATE TABLE hospitals (
facility_id INTEGER PRIMARY KEY,
name TEXT NOT NULL,
name_bangla TEXT,
code INTEGER,
agency TEXT,
facility_type TEXT,
division TEXT,
district TEXT,
city_corporation TEXT,
upazila TEXT,
is_private INTEGER NOT NULL DEFAULT 0
);
"""
清洗函数会将原始 CSV 的表头映射到这些架构名称,将标识符和私有标志转换为数字(无效值会变为空值而非导致程序崩溃),删除缺少 id 或 name 的行,并移除重复的设施信息:
def clean_hospitals(df: pd.DataFrame) -> pd.DataFrame:
rename_map = {
"Id": "facility_id", "Name": "name", "Name (Bangla)": "name_bangla",
"Code": "code", "Agency": "agency", "Type": "facility_type",
"Division": "division", "District": "district",
"City Corporation": "city_corporation", "Upazila": "upazila",
"Private": "is_private",
}
out = _select_and_rename(df, rename_map, required=["facility_id", "name"]).copy() out["facility_id"] = pd.to_numeric(out["facility_id"], errors="coerce").astype("Int64")
out["is_private"] = pd.to_numeric(out["is_private"], errors="coerce").fillna(0).astype(int) out = out.dropna(subset=["facility_id", "name"]).drop_duplicates(subset=["facility_id"])
return out.reset_index(drop=True)
请注意对 _select_and_rename 的调用,你可能会以为它应该是普通的 df.rename()。实际上这个辅助函数是在出现真正故障后才添加的,相关情况详见下文的错误部分。这是一个防御性编程的典型例子,只有在你知道出了什么问题时才能明白它的作用。
一个可复用的文本转SQL工具工厂
这三种数据库工具都遵循相同的处理流程,因此只需将其实现一次作为工厂函数,然后调用三次。该工厂通过 SQLDatabase.from_uri 打开数据库,使用 create_sql_query_chain 构建用于生成SQL语句的链式结构,用 QuerySQLDatabaseTool 创建用于执行查询的工具,最后结合提示词、模型以及字符串解析器来组成用于格式化结果的链式结构:
def build_db_qa_tool(*, tool_name: str, description: str, db_path: Path, llm) -> Tool:
db = SQLDatabase.from_uri(f"sqlite:///{db_path}")
write_query = create_sql_query_chain(llm, db)
execute_query = QuerySQLDatabaseTool(db=db)
format_answer = _ANSWER_PROMPT | llm | StrOutputParser()
内部函数负责处理每个问题的实际工作。它向模型请求SQL语句,提取第一个SELECT语句,设置只读访问权限和行数限制,执行该语句,然后将问题、查询语句及原始结果传递给格式化处理流程。任何异常都会被转换为易于理解的文字描述而非堆栈跟踪信息,这样代理也能有依据地进行推理:
def _answer(question: str) -> str:
try:
raw_sql = write_query.invoke({"question": question})
sql = _enforce_read_only_and_limit(_first_select_statement(raw_sql), MAX_SQL_RESULT_ROWS)
result = execute_query.invoke(sql)
except Exception as exc:
return f"I could not query the {tool_name} database for that question (reason: {exc})." return format_answer.invoke({"question": question, "query": sql, "result": result}) return Tool(name=tool_name, description=description, func=_answer)
在初始版本中,这段代码中的三个设计选择很容易被忽略:
- 模型通过
create_sql_query_chain生成SQL语句,无需手动维护与数据库结构同步的查询模板。
_enforce_read_only_and_limit 会拒绝非 SELECT 类型的查询,并限制返回的行数,这样那些被误导或操纵的模型就无法修改数据或将整个表的内容导出到上下文窗口中。如果采用这种模式,还有一个有用的额外防护措施,那就是以只读模式打开 SQLite 文件。虽然可以通过一些巧妙的方法绕过对 SQL 字符串的检查,但无法进行写入操作的数据库连接能提供第二道防御屏障。
让工具描述负责路由
此时脚本就充当了代理的角色。系统提示会首先明确助手的职责:
SYSTEM_PROMPT = """You are a multi-tool research assistant for Bangladesh.
接着它列出了四种工具,并说明了三条路由规则:对于计数、列表或特定记录,使用对应的数据库工具,切勿估算数值;对于一般性知识,使用网络搜索;当问题涉及多个数据集时,需调用每个相关的工具并合并结果。在提示语下方,create_tool_calling_agent将模型、工具和提示语结合在一起,而AgentExecutor则运行循环,最多迭代六次,并能容错处理格式错误的工具调用:
You have four tools:
- InstitutionsDBTool : Bangladeshi schools, colleges, madrashas (EIIN registry)
- HospitalsDBTool : Bangladeshi government health facilities and offices
- RestaurantsDBTool : Bangladeshi restaurants and eateries (Google Places data)
- WebSearchTool : live web search, for anything the three databases don't coverRouting rules:
1. If the question asks for a count, list, or specific record from one of the
three datasets, call the matching DB tool. Never guess numbers yourself.
2. If the question is general knowledge or not covered by any DB tool, call
WebSearchTool.
3. If a question spans more than one dataset, call each relevant tool
separately and combine the results.
"""agent = create_tool_calling_agent(llm=llm, tools=tools, prompt=prompt)
executor = AgentExecutor(agent=agent, tools=tools, max_iterations=6, handle_parsing_errors=True)
此处没有内容与关键词匹配、应用正则表达式或将短语映射到工具上。模型会将问题与每条描述进行比较并做出选择。因此,这些描述成为代码库中最重要的字符串:如果它们表述模糊或存在重叠,智能体就会开始错误地选择工具,而无论在其他地方如何调整提示语都无法完全弥补这一问题。同时,保持max_iterations=6的限制也很重要,因为它能够控制成本,并防止智能体无限循环地调用工具。
为何选择传统的AgentExecutor而非LangGraph
LangChain 正逐步将其推荐的智能体架构转向 LangGraph,因为后者能更精细地控制多步骤、有状态的工作流。对于这类应用场景,使用带有 AgentExecutor 的 create_tool_calling_agent 更为合适:每个请求包含一个问题、一到两次工具调用以及一个答案。而图框架则会为问题增添不必要的结构。选择能够解决问题且规模最小的架构本身就是一门技能,这与知晓存在更强大的选项是两回事。如果后续的路由处理需要分支、重试或持久化状态,那时图框架才真正体现出其价值;关于设计具备弹性的智能体图结构的文章探讨了这一方向。
三种与模型无关的故障
在构建过程中最有启发性的是那些第一次尝试时没有成功的东西,而非那些成功的部分。这三个问题都与提示词或模型质量无关。
并行安装的两个 LangChain 版本
LangChain 1.0 对其 agents 模块进行了重组,将传统的 AgentExecutor 模式移至了独立的包中。由于 Colab 环境中同时安装了这两个版本,因此在某个单元格中成功导入的代码,在下一个单元格中却会引发 ImportError: cannot import name 'AgentExecutor' 错误。
解决方案属于流程调整而非代码修改:需明确卸载冲突的软件包,指定确切版本而非版本范围,然后在继续操作前重启运行时环境。维护依赖关系是一项真正的工程工作。请查看当前版本的 LangChain 文档,了解 AgentExecutor 的位置,因为不同版本中这一信息会有所变化。
列名使用空格而非下划线
Hugging Face 数据集查看器显示的列名为 INSTITUTE_NAME,但实际上下载的文件中使用的是 INSTITUTE NAME。这一个字符就在距离真正问题几行代码之外就引发了 KeyError 错误。
返回的结果正是之前见过的 _select_and_rename 辅助函数。它在比较列名之前会去除所有非字母数字字符,因此 INSTITUTE_NAME、INSTITUTE NAME 和 institute-name 都会被视为匹配。当仍无法找到必需的列时,它会输出实际的列列表,而不会默默失败。一条好的错误信息本身就是一种调试工具。
Gemini API 拒绝的 API 密钥格式
Google 推出了新的 API 密钥格式,其开头为 AQ.,而非常见的 AIzaSy。在构建这个项目时,直接使用新格式的密钥会遭到 Gemini API 的拒绝,并返回含义不明的 401 ACCESS_TOKEN_TYPE_UNSUPPORTED 错误。重试逻辑无法解决端点根本不接受的凭证问题。
解决方案是完全改变认证路径:该代理现在通过 OAuth 向 Google Vertex AI 进行认证,与其他任何 Google Cloud 服务相同。这样可以避免密钥格式的问题,而且从实际应用角度来看也是更合适的设置。由于这一机制可能已经发生变化,在依赖它之前请务必查阅最新的 Google Cloud 文档进行确认。
这三种情况的共同点在于:在当前的 AI 工程实践中,与模型相关的各种组件(包版本、数据架构及认证流程)出问题的频率远高于模型本身。
代理的实际应用
一旦数据库搭建完成且代理连接就绪,关于拉杰沙希地区各学院的统计数据查询就会直接发送到相关机构工具,该工具会从数据库中提取数据并给出答案:
You: How many colleges are registered in Rajshahi division?
> Entering new AgentExecutor chain...
[Tool: InstitutionsDBTool] → "There are 812 colleges registered in Rajshahi division."
在同一个会话中,关于DGHS的常识问题被发送到网络搜索功能,而查询查特戈姆地区政府医院的请求则被发送到医院数据库:
You: What is the role of DGHS in Bangladesh?
> Entering new AgentExecutor chain...
[Tool: WebSearchTool] → "DGHS (Directorate General of Health Services) is the
government body responsible for planning, managing, and delivering public
healthcare services across Bangladesh..."You: List a few government hospitals in Chattogram district.
> Entering new AgentExecutor chain...
[Tool: HospitalsDBTool] → "Chattogram district includes several government
health facilities such as Chattogram Medical College Hospital and Chattogram
General Hospital, both operating under DGHS..."
在单次对话中使用了三种不同的数据源,但没有任何代码指示智能体应选择哪一种。模型完全依据工具描述来做出决策。
需在下次开发中注意的事项
- 在编写数据结构之前先验证数据。如果在构建工具后才发现数据集的缺失,那就意味着必须重新编写全部三个工具。
- 应以比必要更严格的方式锁定依赖关系。在每隔几个月就需要进行重大重写的开发环境中,今天看似可行的版本范围,几周后在新环境中可能会产生完全不同的结果。
总结
一旦为 Gemini 提供清晰、准确的工具描述,其工具调用功能就能正确运行。模型本身并非难点,真正耗费时间的是相关的基础设施:版本冲突的库、列标题中的多余空格,以及 API 无法识别的凭证格式。如果要构建多工具代理,应尽早在三方面下功夫:准确的工具描述、对模型生成的执行内容进行严格约束,以及可复现的环境。这些因素决定了该代理在实际应用中的表现是否与演示时一样良好。