关键技术:LangChain + 通义千问 + FastAPI + MySQL + Pinecone向量数据库 + 高德地图API
完整代码实现:覆盖Web服务搭建、MySQL数据库封装、Pinecone向量数据库同步、高德地图配送范围查询的全套业务工具
注:结尾附完整代码下载
一、项目背景:AI正在颠覆餐饮行业
2026年3月,肯德基正式上线了基于阿里通义千问大模型的AI点餐智能体“小K”,用户无需通过繁琐的菜单层级点击,只需输入如“10人开会、预算350元内、包含指定数量汉堡”等模糊指令,系统即可自动匹配口味并完成套餐配置。这标志着AI点餐已经从概念演示走向真正的商业落地。
在这个大背景下,我们来复刻并超越这套系统,构建一个具备智能菜品推荐、餐厅信息查询和配送范围检查能力的智能点餐系统。项目采用的技术栈涵盖了大模型应用开发的方方面面,极具实战参考价值。
为什么说它“极具参考价值”?因为这套系统几乎用到了AI应用开发的所有核心组件:**
-
大模型框架
:LangChain,目前最流行的AI应用开发框架
-
AI模型
:通义千问,阿里云开源的大语言模型
-
后端框架
:FastAPI,高性能的Python Web框架
-
数据库
:MySQL + Pinecone向量数据库,兼顾结构化存储和语义检索
-
地图服务
:高德地图API,实现门店地址地理编码和配送范围计算
本文将按照“Web应用搭建 → 工具封装 → 智能体构建”的路线,逐层递进,完整呈现开发全过程。每段代码都有详细注释,确保可以直接运行。
二、技术栈全景解析
2.1 FastAPI:高性能异步Web框架
先来看Web层的技术选型。FastAPI是2018年诞生的Python Web框架,其核心定位是解决传统框架(如Flask、Django)在性能和开发效率方面的痛点。
为什么选择FastAPI? 三个核心理由:**
-
异步性能
:传统WSGI框架(如Flask)采用同步阻塞模型,在处理数据库查询、外部API调用等I/O密集型任务时,单个线程必须等待I/O完成才能处理下一个请求,性能受限。而FastAPI基于Starlette(ASGI框架)构建,支持async/await异步请求处理,单核QPS可达传统框架的3-5倍。
-
自动验证与文档
:FastAPI与Pydantic深度集成。通过定义数据模型,框架自动完成请求体验证、路径参数转换、查询参数校验,并自动生成OpenAPI规范。配合Swagger UI,API对接效率提升3倍以上。
-
开发效率
:数据验证逻辑从业务代码中完全解耦,减少80%以上的手动校验代码。
2.2 LangChain:AI应用开发的“瑞士军刀”
LangChain是目前最流行的AI应用开发框架,它为大语言模型(LLM)提供了一套标准的开发范式,包括:链式调用(Chains)、记忆管理(Memory)、工具集成(Tools)、智能体(Agents)。简单来说,LangChain就像一个“连接器” ,把大模型、数据库、API等不同组件串联成一个完整的AI应用,让开发者不必每次都从零开始构建这些基础能力。
项目中LangChain的核心应用场景:
-
通义千问模型的集成调用
-
文本向量化(使用text-embedding-v4模型)
-
工具定义与智能体对话流程编排
而MCP(Model Context Protocol)作为AI模型与外部工具交互的标准化协议,在项目中同样扮演着重要角色——它让大语言模型可以像调用本地函数一样安全、高效地调用数据库、API和地图服务,每次具体实现则封装在各业务工具模块中。
2.3 向量检索(RAG):让AI“懂”你的菜单
这是整个系统的技术核心。RAG(检索增强生成)的核心思想是:当用户提问时,系统先在知识库(向量数据库)中检索相关信息,然后把检索到的信息作为上下文“喂”给大模型,让模型基于这些真实数据生成回答。而不是让大模型凭空“编造”答案。
在点餐系统中的好处:
-
菜品信息(价格、口味、过敏原等)不需要预先“教”给大模型
-
菜单更新时,只需同步向量数据库,大模型即刻“知道”新菜品
-
回答更准确,避免模型“幻觉”编造菜品信息
具体技术实现链路:
-
DashScope文本向量模型
:阿里云提供的text-embedding-v4模型,属于通义千问大模型体系的专用向量化模型,核心作用是将文本转换为固定维度的浮点数向量(1536维),相似语义的文本向量在空间中距离更近。对该1536维向量进行余弦相似度计算,系统就能从向量数据库中快速找到最相关的菜品推荐。
2.4 高德地图API:智能配送的核心
路径规划是高德地图API的核心能力——用户只输入地址,系统通过“地理编码”将其转换为经纬度坐标,再由高德返回骑行的具体路线距离。
为什么要用骑行模式而非直线距离? 逻辑很简单:直线距离完全不靠谱。 一条河可能把明明很近的两点隔得非常远,骑行模式返回的才是真正的道路里程。
三、项目结构一览
smart_dian_can/
├── api/ # API接口层
│ ├── main.py # FastAPI主应用
│ └── models.py # 数据模型定义
├── agent/ # 智能体层
│ ├── mcp.py # 工具定义(核心文件)
│ └── smart_agent.py # 智能助手
├── tools/ # 工具层
│ ├── amap_tool.py # 高德地图工具
│ ├── db_tool.py # 数据库工具
│ ├── llm_tool.py # LLM调用工具
│ └── pinecone_tool.py # 向量数据库工具
├── service/ # 服务层
│ └── diancan_service.py # 业务服务
├── prompt/ # 提示词模板
├── run.py # 启动脚本
└── requirements.txt # 依赖文件
四、环境准备与配置
4.1 依赖安装
pip install -r requirements.txt
主要依赖及作用:
依赖包 | 版本要求 | 作用 |
fastapi | ≥0.100.0 | Web框架,提供路由、中间件等核心能力 |
uvicorn[standard] | ≥0.23.0 | ASGI服务器,运行FastAPI应用 |
mysql-connector-python | ~9.4.0 | MySQL官方驱动 |
pinecone | ~7.3.0 | Pinecone向量数据库客户端 |
dashscope | ≥1.14.0 | 阿里云大模型API调用 |
langchain | ≥1.0.7 | AI应用开发框架 |
4.2 环境变量配置(.env文件)
# 高德地图API配置
AMAP_API_KEY=your_amap_api_key
MERCHANT_LONGITUDE=114.401934
MERCHANT_LATITUDE=30.465295
DELIVERY_RADIUS=2500
DEFAULT_PATH_MODE=2
# 通义千问配置
DASHSCOPE_API_KEY="your_dashscope_api_key"
LLM_MODE="qwen-plus"
# Pinecone向量数据库配置
PINECONE_API_KEY="your_pinecone_api_key"
PINECONE_ENV=us-east-1
# MySQL数据库配置
MYSQL_HOST="your_host"
MYSQL_PORT="3306"
MYSQL_USER_NAME="your_username"
MYSQL_USER_PASSWORD="your_password"
MYSQL_DB_NAME="menu"
五、Web应用搭建:FastAPI实战
5.1 创建FastAPI实例和数据模型
用最简单的话来说:FastAPI是一个Web框架,它负责接收用户从网页发来的请求,处理后返回结果。用户输入的地址、菜品查询等,都通过HTTP请求发送给FastAPI,FastAPI根据请求的路径(如/chat)调用对应的处理函数,最后把结果返回给用户。
数据模型的作用就像事先画好的“表格模板”——告诉FastAPI收到的请求应该长什么样,这样FastAPI可以自动验证数据是否正确,省去大量手写校验代码。
代码实现:
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel
from typing import Optional, List
import sys
import os
#【关键1】创建FastAPI应用实例
# 这个app就是整个Web服务器的核心对象
app = FastAPI(
title="AiMenu智能点餐系统",
description="智能餐厅助手API,提供智能对话、配送查询和菜品列表服务",
version="2.0.0"
)
#【关键2】请求模型:定义前端发送请求时数据需要的格式
class ChatRequest(BaseModel):
"""智能对话请求——用户在聊天窗口输入的内容"""
query: str # 用户输入的问题,如"推荐几个川菜"
class DeliveryRequest(BaseModel):
"""配送查询请求——用户输入的地址信息"""
address: str # 用户输入的地址
travel_mode: Optional[int] = 3 # 1=步行,2=骑行,3=驾车
#【关键3】响应模型:定义后端返回给前端的数据格式
class ChatResponse(BaseModel):
"""智能对话响应——AI回复的内容"""
success: bool # 操作是否成功
query: str # 用户原始问题
response: Optional[str] = None # AI的普通文字回复
recommendation: Optional[str] = None # 菜品推荐文本
menu_ids: Optional[List[str]] = None # 被推荐的菜品ID列表
5.2 核心API路由实现
三个核心接口的设计思路:
-
/chat(智能对话):用户说“推荐一个不辣的素食”,系统先从Pinecone向量数据库检索最相关的菜品,再把这些菜品信息连同对话历史一起发给大模型,大模型基于真实数据生成推荐回答,而不是凭空编造。
-
/delivery(配送查询):用户输入地址,系统调用高德地图API将该地址转为经纬度坐标,计算出与商家位置之间的实际骑行/驾车距离,判断是否在配送范围内。
-
/menu/list(菜品列表):前端需要展示完整的菜单,直接查询MySQL返回所有菜品的结构化数据。
代码实现:
#【关键4】智能对话接口
@app.post("/chat", response_model=ChatResponse)
async def chat_endpoint(request: ChatRequest):
"""
智能对话接口
用户输入任意问题,调用智能助手获取回答
"""
try:
# 调用核心服务层的智能对话函数
result = smart_chat(request.query)
# 判断返回结果类型,区分普通回答和菜品推荐
if isinstance(result, dict) and "recommendation" in result:
# 这是菜品推荐类型——返回推荐内容和对应的菜品ID
return ChatResponse(
success=True,
query=request.query,
recommendation=result["recommendation"],
menu_ids=result["menu_ids"]
)
else:
# 这是普通文本回复
return ChatResponse(
success=True,
query=request.query,
response=str(result)
)
except Exception as e:
raise HTTPException(
status_code=500,
detail=f"智能对话服务失败: {str(e)}"
)
#【关键5】配送查询接口
@app.post("/delivery", response_model=DeliveryResponse)
async def delivery_endpoint(request: DeliveryRequest):
"""
配送查询接口
检查用户输入的地址是否在配送范围内
"""
try:
# 调用配送查询服务
result = delivery_check(request.address, request.travel_mode)
if result["status"] == "success":
return DeliveryResponse(
success=True,
in_range=result["in_range"],
distance=result["distance"],
formatted_address=result["formatted_address"],
message=result["message"],
travel_mode=request.travel_mode,
input_address=request.address
)
else:
# 查询失败时的降级返回
return DeliveryResponse(
success=False,
in_range=False,
distance=0.0,
formatted_address=request.address,
message=result["message"],
travel_mode=request.travel_mode,
input_address=request.address
)
except Exception as e:
raise HTTPException(
status_code=500,
detail=f"配送查询服务失败: {str(e)}"
)
#【关键6】菜品列表查询接口
@app.get("/menu/list", response_model=MenuListResponse)
async def menu_list_endpoint():
"""
菜品列表接口
返回所有菜品的结构化数据,供前端页面展示
"""
try:
# 从数据库获取所有菜品的列表数据
structured_data = get_menu_items_list()
if not structured_data:
return MenuListResponse(
success=False,
menu_items=[],
count=0,
message="当前没有可用的菜品信息"
)
menu_count = len(structured_data)
return MenuListResponse(
success=True,
menu_items=structured_data,
count=menu_count,
message=f"成功获取 {menu_count} 个菜品信息"
)
except Exception as e:
raise HTTPException(
status_code=500,
detail=f"菜品列表服务失败: {str(e)}"
)
#【关键7】健康检查接口
@app.get("/health")
async def health_check():
"""健康检查接口——用于监控系统是否正常运行"""
return {"status": "healthy", "service": "AiMenu API"}
uvicorn服务器启动:
# run.py 文件
import uvicorn
def main():
print("️ AiMenu 智能点餐系统 v2.0")
print("=" * 50)
print("✅ 环境配置检查通过")
print(" 正在启动API服务...")
print(" 服务地址: http://localhost:8000")
print(" API文档: http://localhost:8000/docs")
print("=" * 50)
# 启动uvicorn服务器
# "api.main:app" 表示从api/main.py文件中导入app实例
# reload=True 开启热重载,代码修改后自动重启
uvicorn.run(
"api.main:app",
host="0.0.0.0", # 允许外部访问
port=8000,
reload=True,
log_level="info"
)
if __name__ == "__main__":
main()
运行python run.py后,访问http://localhost:8000/docs即可看到自动生成的API文档界面(Swagger UI),可以直接在网页上测试所有接口。
六、MySQL数据库工具封装
6.1 连接池与上下文管理器
用最简单的话来说:数据库连接是一个比较昂贵的操作(建立TCP连接、验证身份等都需要时间),不能每次查询都重新连接。更好的方式是:使用前建立连接,使用后自动释放,确保不会“忘记关连接”导致资源泄露。
类实现思路:
-
__init__存储连接配置
-
connect()建立连接
-
dis_connect()关闭连接
-
__enter__和__exit__上下文管理器,确保连接被自动释放
代码实现:
import mysql.connector
import logging
from dotenv import load_dotenv
import os
load_dotenv()
logger = logging.getLogger(__name__)
class DataBaseConnection:
"""
数据库连接管理器,支持上下文管理器(with语句)
使用示例:
with DataBaseConnection() as db:
db.cursor.execute("SELECT * FROM menu_items")
results = db.cursor.fetchall()
# 离开with代码块时,连接被自动关闭
"""
def __init__(self):
# 从环境变量读取数据库配置
self.connection = None
self.cursor = None
self.host = os.getenv("MYSQL_HOST", "localhost")
self.port = int(os.getenv("MYSQL_PORT", "3306"))
self.user = os.getenv("MYSQL_USER", "root")
self.password = os.getenv("MYSQL_PASSWORD", "root")
self.database = os.getenv("MYSQL_DATABASE", "menu")
def connect(self) -> bool:
"""建立数据库连接"""
try:
self.connection = mysql.connector.connect(
host=self.host,
port=self.port,
user=self.user,
password=self.password,
database=self.database,
charset="utf8mb4" # 完整支持中文
)
if self.connection.is_connected():
# dictionary=True 让返回结果为字典格式,而非元组
self.cursor = self.connection.cursor(dictionary=True)
logger.info(f"已成功建立数据库连接,数据库: {self.database}")
return True
return False
except mysql.connector.Error as e:
logger.error(f"数据库连接错误: {e}")
return False
def dis_connect(self):
"""关闭数据库连接——先关游标,再关连接"""
try:
if self.cursor:
self.cursor.close()
self.cursor = None
if self.connection and self.connection.is_connected():
self.connection.close()
logger.info(f"已成功关闭数据库连接,数据库: {self.database}")
self.connection = None
except mysql.connector.Error as e:
logger.error(f"关闭数据库连接错误: {e}")
raise
# 上下文管理器入口
def __enter__(self):
if self.connect():
return self
else:
raise Exception("无法建立数据库连接")
# 上下文管理器出口——无论是否发生异常,都会执行dis_connect
def __exit__(self, exc_type, exc_val, exc_tb):
self.dis_connect()
6.2 菜品查询函数实现
(1)获取菜品作为文本字符串
为什么需要这个函数? 向量数据库(Pinecone)需要把菜品数据转成向量后才能检索。这条函数的作用是将数据库中的菜品数据转换成一段完整的文本描述(如“菜品ID:1|菜品名称:麻婆豆腐|辣度:中辣”),便于后续的向量化处理。
def get_all_menu_items() -> str:
"""获取所有菜单项,返回拼接后的字符串(向量数据库使用)"""
try:
with DataBaseConnection() as db:
query_sql = """
SELECT id, dish_name, price, description, category,
spice_level, flavor, main_ingredients, cooking_method,
is_vegetarian, allergens, is_available
FROM menu_items
WHERE is_available = 1
ORDER BY category, dish_name
"""
db.cursor.execute(query_sql)
menu_items = db.cursor.fetchall()
if not menu_items:
return "当前没有找到任何菜品信息"
menu_strings = []
for item in menu_items:
# 数据处理:空值替换 + 辣度数字转文本 + 布尔值转文本
description_text = item.get('description', '') or "未知描述"
# 辣度等级转换字典
spice_level = {"0": "不辣", "1": "微辣", "2": "中辣", "3": "重辣"}
spice_text = spice_level.get(str(item["spice_level"]), "未知辣度")
vegetarian_text = "是" if item['is_vegetarian'] else "否"
# 将所有菜品信息拼接成一行字符串
menu_string = f"菜品ID:{item['id']}|菜品名称:{item['dish_name']}|价格:¥{item['price']:.2f}|菜品描述:{description_text}|分类:{item['category']}|辣度:{spice_text}|口味:{item['flavor']}|主要食材:{item['main_ingredients']}|烹饪方法:{item['cooking_method']}|素食:{vegetarian_text}|过敏原:{item['allergens'] or '无'}"
menu_strings.append(menu_string)
# 所有菜品以换行符连接,返回完整字符串
all_menu_info = "\n".join(menu_strings)
return all_menu_info
except Exception as e:
logger.error(f"查询菜品信息失败: {e}")
return "查询菜品信息失败"
(2)获取菜品结构化列表
为什么需要这个函数? 前端需要展示菜品列表(带图片、价格、描述等),这个函数返回结构化的字典列表,可以直接转换为JSON返回。
def get_menu_items_list() -> List[dict]:
"""获取所有菜单项,返回字典列表(API接口使用)"""
try:
with DataBaseConnection() as db:
query_sql = """
SELECT id, dish_name, price, description, category,
spice_level, flavor, main_ingredients, cooking_method,
is_vegetarian, allergens, is_available
FROM menu_items
WHERE is_available = 1
ORDER BY category, dish_name
"""
db.cursor.execute(query_sql)
menu_items_result = db.cursor.fetchall()
if not menu_items_result:
return []
menu_items = []
for item in menu_items_result:
spice_levels = {0: "不辣", 1: "微辣", 2: "中辣", 3: "重辣"}
spice_text = spice_levels.get(item['spice_level'], "未知")
# 构建前端友好的字典结构
processed_item = {
"id": item['id'],
"dish_name": item['dish_name'],
"price": float(item['price']),
"formatted_price": f"¥{item['price']:.2f}",
"description": item['description'] or "暂无描述",
"category": item['category'],
"spice_level": item['spice_level'],
"spice_text": spice_text,
"is_vegetarian": bool(item['is_vegetarian']),
"vegetarian_text": "是" if item['is_vegetarian'] else "否",
"allergens": item['allergens'] or "暂无过敏原",
}
menu_items.append(processed_item)
return menu_items
except Exception as e:
logger.error(f"查询菜品结构化信息失败: {e}")
return []
七、Pinecone向量数据库封装
7.1 核心概念与设计思路
为什么要用向量数据库? 向量数据库(Pinecone)是一个专门存储和检索“向量”的数据库。这里的“向量”可以理解为一串数字(如1536个浮点数),代表一段文本的语义特征。当用户输入“推荐一个辣的菜”时,系统将该查询文本也转换成向量,然后在向量数据库中查找最相似的菜品向量。类似语义的文本在向量空间中的距离更近。
Pinecone的优劣势:作为托管云服务,它无需运维、支持毫秒级查询,但免费额度有限。本地开发也可选择Chroma或Qdrant作为替代方案。
类封装的设计思路:
-
__init__:从环境变量读取API Key、索引名称、向量维度等配置
-
initialize():初始化Pinecone客户端,如索引不存在则创建
-
get_dashscope_embedding():调用通义千问text-embedding-v4模型生成文本向量
-
upsert_menu_data():将菜品数据从MySQL同步到Pinecone
-
similar_search_items():根据查询文本检索相似的菜品
代码实现:
import os
import dashscope
from pinecone import Pinecone
from pinecone.models import ServerlessSpec
from langchain.text_splitter import CharacterTextSplitter
import logging
logger = logging.getLogger(__name__)
class PineconeVectorDB:
"""Pinecone向量数据库管理类"""
def __init__(self):
"""读取配置,准备连接参数"""
self.dashscope_api_key = os.getenv("DASHSCOPE_API_KEY")
self.pinecone_api_key = os.getenv("PINECONE_API_KEY")
self.pinecone_env = os.getenv("PINECONE_ENV", "us-east-1")
# 向量数据库参数——维度必须与Embedding模型输出维度一致
self.index_name = "menu-items1" # 菜品索引名称
self.model_name = "text-embedding-v4" # DashScope嵌入模型
self.dimension = 1536 # 1536是text-embedding-v4支持的高维向量
self.pc = None # Pinecone客户端
self.index = None # 索引对象
初始化连接: 调用Pinecone(api_key)创建客户端,检查索引是否存在,不存在则创建新索引,指定1536维度和余弦相似度度量。
def initialize(self) -> bool:
"""初始化Pinecone连接和索引"""
try:
if not self.pinecone_api_key:
return False
self.pc = Pinecone(api_key=self.pinecone_api_key)
# 检查索引是否存在,不存在则创建
if not self.pc.has_index(self.index_name):
self.pc.create_index(
name=self.index_name,
dimension=self.dimension, # 必须与Embedding模型维度一致
metric="cosine", # 使用余弦相似度衡量语义相关性
spec=ServerlessSpec(
cloud="aws",
region=self.pinecone_env
)
)
self.index = self.pc.Index(self.index_name)
return True
except Exception as e:
logger.error(f"初始化Pinecone连接失败: {e}")
return False
向量生成与同步:
def get_dashscope_embedding(self, text: str) -> List[float]:
"""使用DashScope生成文本向量"""
try:
resp = dashscope.TextEmbedding.call(
model=self.model_name,
input=text,
dimension=self.dimension
)
if resp.status_code == 200:
return resp["output"]["embeddings"][0]["embedding"]
return []
except Exception as e:
logger.error(f"生成文本向量失败: {e}")
return []
def upsert_menu_data(self, menu_data: str = None, clear_existing: bool = True) -> bool:
"""批量插入菜品数据到Pinecone"""
try:
if not self.index and not self.initialize():
return False
# 是否清空现有数据
if clear_existing:
self.delete_all_items_vector_data()
# 获取菜品数据
if menu_data is None:
menu_data = self._get_menu_data_from_db()
if not self._is_valid_menu_data(menu_data):
return False
# 分割菜品数据(每行一个菜品)
lines = self._split_menu_data(menu_data)
if not lines:
return False
# 批量处理——减少网络请求次数
batch = []
for line_num, line in enumerate(lines, 1):
vector = self.get_dashscope_embedding(line)
if vector and len(vector) == self.dimension:
metadata = {
"content": line,
"line_number": line_num,
"type": "menu_item",
}
record_id = f"menu_{line_num}"
batch.append((record_id, vector, metadata))
# 每100条同步一次
if len(batch) >= 100:
self.index.upsert(vectors=batch)
batch = []
if batch:
self.index.upsert(vectors=batch)
return True
except Exception as e:
logger.error(f"批量同步数据失败: {e}")
return False
语义搜索与ID提取: 用户输入查询文本,系统生成对应向量,在Pinecone中检索最相似的菜品,返回菜品内容和相似度分数。从菜品内容中用正则表达式提取真实菜品ID(如菜品ID:1),供前端调用详情接口获取完整菜品信息。
def similar_search_items(self, query: str, top_k: int = 2) -> List[dict]:
"""语义搜索:根据用户查询找到最相似的菜品"""
try:
if not self.index and not self.initialize():
return []
query_vector = self.get_dashscope_embedding(query)
if not query_vector or len(query_vector) != self.dimension:
return []
result = self.index.query(
vector=query_vector,
top_k=top_k,
include_metadata=True
)
similar_result = []
for item in result['matches']:
match_item = {
"id": item['id'],
"score": item['score'], # 相似度分数
"content": item['metadata']['content'],
"line_number": item['metadata']['line_number']
}
similar_result.append(match_item)
return similar_result
except Exception as e:
logger.error(f"查询向量数据失败: {e}")
return []
def search_menu_items_with_id(self, query: str, top_k: int = 2) -> Dict[str, Any]:
"""语义搜索并提取菜品ID"""
import re
similar_result = self.similar_search_items(query, top_k)
if not similar_result:
return {}
item_ids = []
for item in similar_result:
match = re.search(r'菜品ID:(\d+)', item['content'])
item_ids.append(match.group(1) if match else item["id"])
return {
"contents": [item['content'] for item in similar_result],
"ids": item_ids,
"scores": [item['score'] for item in similar_result]
}
八、高德地图配送查询工具
8.1 核心功能流程
高德地图业务工具实现三个核心功能:
功能一:地理编码(地址→坐标) 。用户输入的文本地址(如“武汉市洪山区光谷天地”)需要先转换为经纬度坐标,才能用于距离计算。调用/v3/geocode/geo接口实现。
功能二:路线规划(计算距离和时间) 。得到起点(商家)和终点(用户地址)的经纬度后,通过/v5/direction/electrobike等接口计算实际骑行距离。
功能三:配送范围判定 。将计算出的距离(米)与预设的DELIVERY_RADIUS(如2500米)进行比较。
8.2 异常与安全处理
高德地图API是网络调用,不可预测因素较多。代码中实现了三层容错:
-
SSL降级
:requests.exceptions.SSLError时自动降级到HTTP
-
自动重试
:遇到429限流或5xx服务器错误时最多重试3次
-
超时控制
:每次请求设置10秒超时
完整代码实现:
import requests
import os
from dotenv import load_dotenv
from requests.adapters import HTTPAdapter
from urllib3 import Retry
load_dotenv()
logger = logging.getLogger(__name__)
# 配置管理类
@dataclass
class Config:
API_KEY: str = os.getenv("AMAP_API_KEY")
MERCHANT_LONGITUDE: str = os.getenv("MERCHANT_LONGITUDE")
MERCHANT_LATITUDE: str = os.getenv("MERCHANT_LATITUDE")
DELIVERY_RADIUS: int = int(os.getenv("DELIVERY_RADIUS", "2500"))
DEFAULT_PATH_MODE: str = os.getenv("DEFAULT_PATH_MODE", "2")
def __post_init__(self):
if not self.API_KEY:
raise ValueError("AMAP_API_KEY 环境变量未设置")
# 路径模式转换
class PathModeConverter:
MODE_MAPPING = {"1": "walking", "2": "bicycling", "3": "driving"}
@classmethod
def to_mode(cls, mode_input: str) -> str:
if mode_input in cls.MODE_MAPPING:
return cls.MODE_MAPPING[mode_input]
raise ValueError(f"不支持的路径模式: {mode_input}")
地理位置编码实现:
def geocode_address(address: str) -> Dict[str, Any]:
"""地理编码:将地址转换为经纬度坐标"""
try:
base_url = "https://restapi.amap.com/v3/geocode/geo"
params = {
"key": config.API_KEY,
"address": address,
"output": "JSON"
}
response_data = safe_request(base_url, params)
if response_data["status"] == "1" and int(response_data["count"]) > 0:
geocodes = response_data["geocodes"]
return {
"formatted_address": geocodes[0]["formatted_address"],
"location": geocodes[0]["location"], # "经度,纬度"格式
"status": "success"
}
return {"status": "fail", "message": "地理坐标解析失败"}
except Exception as e:
logger.error(f"地理坐标解析失败: {e}")
raise
路径规划与配送检查:
def calculate_distance(origin_location: str, destination_location: str,
path_mode_input: str = "2") -> Dict[str, Any]:
"""计算两点之间的实际路径距离和时间"""
try:
path_mode = PathModeConverter.to_mode(path_mode_input)
endpoints = {
"walking": "https://restapi.amap.com/v5/direction/walking",
"bicycling": "https://restapi.amap.com/v5/direction/electrobike",
"driving": "https://restapi.amap.com/v5/direction/driving"
}
params = {"key": config.API_KEY, "origin": origin_location, "destination": destination_location}
if path_mode == "driving":
params["show_fields"] = "cost"
response = safe_request(endpoints[path_mode], params)
if response.get("status") == "1":
path = response["route"]["paths"][0]
duration = int(path["duration"]) if path_mode == "bicycling" else int(path["cost"]["duration"])
return {"distance": int(path["distance"]), "duration": duration, "status": "success"}
return {"status": "fail", "message": "距离解析失败"}
except Exception as e:
logger.error(f"距离解析失败: {e}")
raise
def check_delivery_range(address: str, path_mode_input: str = None) -> Dict[str, Any]:
"""完整的配送范围检查流程"""
try:
if path_mode_input is None:
path_mode_input = config.DEFAULT_PATH_MODE
# 步骤1:地理编码获取经纬度
geocode_result = geocode_address(address)
if geocode_result['status'] != "success":
return geocode_result
# 步骤2:计算实际路径距离
origin_location = f"{config.MERCHANT_LONGITUDE},{config.MERCHANT_LATITUDE}"
distance_result = calculate_distance(origin_location, geocode_result['location'], path_mode_input)
if distance_result['status'] != "success":
return distance_result
# 步骤3:判断是否在配送范围内
in_range = distance_result['distance'] <= config.DELIVERY_RADIUS
distance_km = round(distance_result['distance'] / 1000, 2)
return {
"status": "success",
"in_range": in_range,
"distance": distance_km,
"duration": distance_result['duration'],
"formatted_address": geocode_result['formatted_address'],
"message": f"配送地址:{geocode_result['formatted_address']}\n配送距离:{distance_km:.2f}公里\n配送状态:{'在配送范围内' if in_range else '超出配送范围'}"
}
except Exception as e:
raise
九、服务层整合指南
service/diancan_service.py将上述工具组合成对外提供的业务能力:
# service/diancan_service.py
from tools.db_tool import get_menu_items_list, get_menu_item_by_id
from tools.pinecone_tool import search_menu_items_with_id
from tools.amap_tool import check_delivery_range
import logging
logger = logging.getLogger(__name__)
def smart_chat(user_query: str):
"""智能对话服务"""
try:
# 向量数据库语义搜索——匹配用户意图相关的菜品
search_result = search_menu_items_with_id(user_query, top_k=3)
if search_result and search_result['ids']:
# 获取完整的菜品信息
menu_details = []
for menu_id in search_result['ids']:
detail = get_menu_item_by_id(menu_id)
if detail:
menu_details.append(detail)
# 构建推荐文本
recommendation = f"根据您的需求,推荐以下菜品:\n"
for item in search_result['contents']:
recommendation += f"• {item.split('|')[1]}\n"
return {
"recommendation": recommendation,
"menu_ids": search_result['ids']
}
else:
return "您好!我是智能点餐助手,请问有什么可以帮您?"
except Exception as e:
logger.error(f"智能对话失败: {e}")
return "服务暂时不可用,请稍后再试"
def delivery_check(address: str, travel_mode: int = 3):
"""配送查询服务"""
return check_delivery_range(address, str(travel_mode))
十、总结与扩展方向
本文完整实现了一个智能点餐系统的核心功能,涵盖Web服务搭建、SQL数据库操作、向量数据库语义检索、地图API集成四大模块。
已解决的问题:
-
智能推荐依赖语言模型“幻觉”
→ 采用RAG架构,所有回答基于向量数据库检索的真实菜品信息
-
系统响应慢
→ FastAPI异步特性保证高并发场景下的吞吐量
-
地址配送判断不准
→ 使用高德骑行API获取实际骑行距离,比直线距离准确得多
-
代码难以维护
→ 分层架构(API→Service→Tools)各司其职,每个层级的职责非常明确
这套代码框架不仅适用于点餐系统,任何需要“知识库问答”的场景——如企业内部知识库助手、智能客服、产品推荐系统——都可以直接复用。
所有的代码文件都给出了完整实现,按照本文顺序组织项目结构,配置好环境变量后即可直接运行。
完整代码下载:pan.baidu.com/s/1yeNQzAJH…