一种在 SQLite 数据库与网络搜索之间进行路由的医疗代理程序
构建一个LangChain智能体,用于判断医疗问题是需要基于三个数据集的SQL查询还是实时网络搜索;同时记录了两次导致设计变更的故障。
来源: Building a Multi-Tool AI Agent That Knows When to Query a Database and When to Search the Web
许多人工智能聊天机器人都功能单一。提出问题后,它们要么凭记忆编造答案,要么因为无法获取相关数据而拒绝回答。真正的系统则需要更智能,能够为每个问题挑选正确的信息来源。这个项目正是为了解决医疗领域的问题而设计的:它是一个多功能智能体,能够读取问题并自行判断答案存在于结构化数据库中还是开放网络中。该智能体可以在三个SQLite数据库与实时网页搜索工具之间切换,而在看似开发完成之后却遇到了两个实际问题。
目标
这项课程作业的要求是设计一个能够完成三项任务的智能体:
- 利用三个真实的医疗数据集回答统计类问题,这些数据集分别涉及心脏病、癌症和糖尿病,每个疾病的数据都存储在独立的SQLite数据库中。
一个大脑,四种工具,以及将每项任务匹配到相应工具的判断能力。
架构
该思维模型充当调度器:
┌────────────────────────┐
│ Main AI Agent │
│ (LangChain create_agent) │
└────────────┬─────────────┘
│ routes based on question type
┌────────────────┬──────────┼──────────┬────────────────┐
▼ ▼ ▼ ▼ ▼
HeartDiseaseDBTool CancerDBTool DiabetesDBTool MedicalWebSearchTool
│ │ │ │
▼ ▼ ▼ ▼
heart_disease.db cancer.db diabetes.db Tavily Search API
(SQLite) (SQLite) (SQLite) (general knowledge)
主代理从不直接处理数据。它的任务是读取问题,选择相应的专家并将任务转交给他。关于心脏病数据集中平均胆固醇水平的问题由 HeartDiseaseDBTool 处理,而关于2型糖尿病成因的问题则由 MedicalWebSearchTool 处理。如果问题同时涉及这两方面,代理会调用这两个工具,并将它们的输出整合成一条连贯的回复。
数据集
三个真实的公开Kaggle医学数据集已被转换为带有正确类型列的SQLite数据库。心脏病数据存储在heart_disease.db中,包含303行数据,涵盖年龄、性别、胆固醇水平、血压值以及目标变量。癌细胞测量数据则存放在cancer.db中,共有101行数据,涉及细胞团厚度、细胞大小、裸露的细胞核以及有丝分裂情况。皮马印第安人糖尿病数据保存在diabetes.db中,包含768行数据,包括葡萄糖浓度、BMI值、妊娠史以及疾病结局。
将CSV文件转换为SQLite看似简单,但要正确完成这一任务就需要为表格指定有意义的名称,并准确推断出列的类型(如INTEGER、REAL、TEXT),而不能将所有数据都当作文本处理。转换逻辑首先建立从pandas数据类型到SQLite类型的映射关系:
DTYPE_MAP = {"int64": "INTEGER", "float64": "REAL", "object": "TEXT", "bool": "INTEGER"}
随后,一个辅助程序会根据数据框的dtype来生成CREATE TABLE语句中的列定义:
def infer_sqlite_schema(df: pd.DataFrame) -> str:
"""I build a CREATE TABLE column definition string from a DataFrame's dtypes."""
columns = []
for col in df.columns:
sqlite_type = DTYPE_MAP.get(str(df[col].dtype), "TEXT")
safe_col = col.replace(" ", "_").replace("-", "_")
columns.append(f'"{safe_col}" {sqlite_type}')
return ", ".join(columns)
一旦数据库建立,其结构就能可靠地反映真实数据,这对于大型语言模型要基于该数据库编写SQL语句而言至关重要。
构建数据库工具
这三种数据库工具都遵循相同的三个步骤,因此一个共享的工厂函数即可替代三份独立的逻辑代码:
- 接收用户用自然语言提出的问题。
- 让大型语言模型以表格结构为依据,将问题转换为仅包含SELECT语句的SQL查询。
- 执行该查询,然后再次提示大型语言模型将查询结果以通俗的英文文字形式呈现。
生成提示会将该模型设定为SQLite专家,并要求其输出语法正确、仅支持读取的SELECT语句:
SQL_GENERATION_PROMPT = ChatPromptTemplate.from_template(
"""You are a SQLite expert. Given an input question and a table schema,
write a syntactically correct, READ-ONLY SQLite SELECT query that answers
the question.
模板的其余部分提供了架构与问题,并要求仅使用原始SQL,不得有任何解释或Markdown格式。其后是一个清理函数:
Table schema:
{table_info}Question: {question}Return ONLY the raw SQL query. No explanation, no markdown formatting.
SQL Query:"""
)def _clean_sql(raw: str) -> str:
"""Strips markdown fences (if any) and blocks write operations."""
sql = raw.strip()
if sql.startswith("```"):
sql = sql.strip("`")
if sql.lower().startswith("sql"):
sql = sql[3:]
sql = sql.strip() forbidden = ("insert", "update", "delete", "drop", "alter", "create")
if sql.lower().startswith(forbidden):
raise ValueError("Write operations are not permitted on this read-only tool.")
return sql
_clean_sql 这个防护机制虽然不起眼,但却起着至关重要的作用。无论大语言模型生成什么内容,代理都无法修改底层数据,因此访问始终为只读模式。
这三个工具各自都是一个简单的封装,其中指定了数据库名称、表名,更重要的是还包含了一段清晰的描述,告诉路由代理何时使用该工具:
heart_disease_tool = make_db_tool(
db_path=HEART_DB_PATH,
table_name="heart_disease_records",
tool_name="HeartDiseaseDBTool",
description=(
"Use this tool to answer STATISTICAL or DATA questions about the "
"Heart Disease dataset (columns: age, sex, cp, trestbps, chol, "
"fbs, restecg, thalach, exang, oldpeak, slope, ca, thal, target). "
"Do NOT use this tool for general medical definitions or symptoms."
),
)
每段描述的最后一句,即明确说明该工具不得用于何种场景的内容,其重要性远超想象。基于大语言模型的路由系统能否成功,关键在于对每款工具的功能描述是否精确。
网络搜索工具
对于一般医学知识查询,Tavily Search API 所对应的工具也有着经过精心设计的描述。该描述将工具的功能限定在一般知识范围内,以避免其与统计类问题混淆:
medical_web_search_tool = StructuredTool.from_function(
func=_run_web_search,
name="MedicalWebSearchTool",
description=(
"Use this tool ONLY for GENERAL medical knowledge questions such "
"as definitions, symptoms, causes, risk factors, treatments, or "
"cures. Do NOT use this tool for statistics or numeric dataset "
"questions."
),
args_schema=WebSearchInput,
)
组装智能体
在四个工具都准备就绪后,最后要做的就是构建路由器:即用通俗语言阐述决策逻辑的系统提示语,再加上LangChain的智能体构造器来将所有组件整合在一起。该提示语首先会告知模型它是一个拥有四种工具的专业医疗数据助手:
SYSTEM_PROMPT = """You are a professional medical data assistant with access to four tools.
以下为必须严格遵循的路由规则。关于数据集中的统计信息、数据、数值、计数、平均值或特定记录的疑问,请使用相应的数据库工具查询;而关于定义、症状、原因或治疗方法的询问,则请通过网络搜索获取答案:
Routing rules (follow strictly):
1. If the question asks about STATISTICS, DATA, NUMBERS, COUNTS, AVERAGES,
or specific RECORDS from a dataset -> use the matching DB tool.
2. If the question asks for a DEFINITION, SYMPTOM, CAUSE, RISK FACTOR, or
CURE/TREATMENT -> use MedicalWebSearchTool.
3. If a question mixes both, call both tools and combine the answers clearly.
4. Never invent numbers. Always answer in clear, professional natural language.
"""TOOLS = [heart_disease_tool, cancer_tool, diabetes_tool, medical_web_search_tool]def build_agent_executor():
llm = get_llm()
return create_agent(model=llm, tools=TOOLS, system_prompt=SYSTEM_PROMPT)
询问心脏病数据集中的平均胆固醇水平,得到的答案约为246.26。再问心脏病的典型症状是什么,该智能体便改变策略,通过网络搜索后返回一份包含胸痛、呼吸急促、疲劳等症状的结构化列表,所有信息均有来源且未经杜撰。
情况突变之时
在开发过程中最具启发性的一幕并非预先计划好的。智能体完成构建后,有两样东西出了问题。
第一个问题是免费的LLM后端消失了。该代理原本是基于GitHub Models构建的,那是一个免费的、兼容OpenAI的推理接口。在测试进行到一半时,所有的调用都开始出现github_models_retirement_brownout错误,因为GitHub已永久停止了该服务。即便对项目自身的代码进行调试也无法解决这个问题。最终的解决办法是将整个后端迁移到Google Gemini,这就需要重新设计config.py,使其能够支持多种服务提供商(Gemini、Azure OpenAI以及直接使用OpenAI),从而避免出现单点故障。
接下来,Gemini的最新模型打破了代理框架的原有结构。谷歌较新的思维模型(3.x系列)会在每次工具调用时附加一个内部的thought_signature标记,客户端必须在下一轮中将此标记传回。原来的代理模型结合了LangChain较旧的create_tool_calling_agent和AgentExecutor,这种组合会在各步骤之间重新构建自己的消息缓存区,同时会无声无息地丢弃该签名。因此每次工具调用都会出现Function call is missing a thought_signature这种难以理解的错误。
没有任何补丁能够解决这个问题。解决办法是改用LangChain当前的create_agent函数,该函数基于LangGraph实现,能够在每轮中保留原始消息对象而非重新生成它们。这样一来,签名会自动附着在消息上,所有相关的错误也就消失了。
更关键的是,智能体系统依赖于不断变化的底层基础设施。服务提供商会停止支持某些端点,而新的模型版本又会带来新的协议要求。这里真正需要考验的技能是判断为何会出现400错误,而不仅仅是理解错误信息的内容,这比任何一行代码都重要。
观察路由器的决策过程
令人满意的是,可以看到该智能体在未被告知具体操作步骤的情况下,一道又一道地做出正确决策。一个示例会话展示了这一过程。
当被询问癌症数据中单个上皮细胞的平均大小时,该智能体调用了CancerDBTool,生成了SELECT AVG(single_epithelial_cell_size) FROM cancer_records;这条查询语句,最终得出平均大小约为2.37的结论。
当被问及糖尿病患者的平均年龄时,系统会调用DiabetesDBTool,先筛选出确诊患者(Outcome = 1),再计算平均值,而非对整个数据集进行统计。最终得出的答案约为37岁。
最后一个问题涉及糖尿病的定义以及整个数据集中的平均血糖水平,这才是真正的考验。系统识别到这是一个复合请求,于是分别调用MedicalWebSearchTool查找定义,再调用DiabetesDBTool获取血糖数据,然后将两个结果整合成一段连贯的文字,尽管并没有人这样要求它。这种行为源于系统提示中的一句话:如果问题同时涉及这两类信息,就调用相应的工具并将答案清晰地合并起来。路由并非魔法,只是精确的指令加上能够良好执行这些指令的模型而已。
用于工具调用的提示工程
在系统构建过程中,最具影响力的决定并非某行 Python 代码,而是每个工具的 description 字段是如何编写的。早期的描述过于模糊,内容多是关于心脏病数据相关问题的解答,由于没有相关规定限制,该系统偶尔会针对统计问题进行网络搜索。
解决办法是让每条描述承担两项功能:明确说明该工具的用途,同时清晰指出其不适用的场景。
"Use this tool to answer STATISTICAL or DATA questions about the Heart
Disease dataset ... Do NOT use this tool for general medical definitions
or symptoms — use MedicalWebSearchTool for those."
写下那第二句话所具有的引导作用,与系统提示语的引导作用大致相当。仅有四个工具且其中一个仅用于通用知识,因此描述中的任何模糊之处都会立刻导致问题被错误引导。真正考验的是那些精确且互不重叠的工具描述,而SQL与API的运用则处于次要位置。
设计层面的安全性
始终遵循以下几项原则:
- 始终为只读访问。所有包含
INSERT、UPDATE、DELETE、DROP或ALTER语句的请求都会被所有SQL工具拒绝,从而确保没有任何工具能够访问数据库。 - 不得编造数据。系统提示语禁止捏造统计数据,如果某个工具没有返回任何结果,智能体也会明确说明。
.env 文件中存放 API 密钥,该文件通过 .gitignore 被排除在版本控制之外,因此没有任何敏感内容会进入代码仓库。技术栈
- LangChain(基于 LangGraph 的
create_agent功能)作为路由核心 - Google Gemini(
gemini-3.5-flash-lite)作为主要大型语言模型 - SQLite 用于以轻量级方式基于文件存储这三类数据集
- Tavily Search API 用于实时检索通用知识
- Python 结合 SQLAlchemy 和 Pandas 作为各组件之间的连接工具
开发过程中的经验教训
让正常流程正常运行只花了一个下午的时间。而增强其稳定性才是耗时较长的部分:该代理需要能够承受某供应商服务停用以及另一供应商接口重大变更带来的影响,这要求开发者耐心分析错误输出,并愿意重新设计架构,而非试图绕过根本性的兼容性问题。对于任何正在构建代理型人工智能系统的人来说,最重要的经验就是要假设模型提供商、API格式甚至代理框架都可能在开发过程中发生变化。只有那些能够应对这些变化的项目才值得推出。
由此可得出三个较小的经验教训:
- 从一开始就对大语言模型提供商进行抽象处理。使用一个能够检查多种可能凭证的
get_llm()函数,而非在各个地方硬编码特定的提供商,使得Gemini的迁移只需进行单文件修改,而无需彻底重写代码。
github_models_retirement_brownout与Function call is missing a thought_signature都像是普通的400错误。问题是需要五分钟解决还是半天才解决,关键在于错误信息的具体内容,而标题根本无法反映实际情况。create_agent的架构后,requirements.txt中的注释详细说明了为何旧版的0.3.x版本不再适用,这样就无需大家再通过试错来发现这个问题。