第十三章 智能旅行助手.md 70 KB

第十三章 智能旅行助手

在前面的章节中,我们从零开始构建了HelloAgents框架,实现了多种智能体范式、工具系统、记忆机制、协议通信和性能评估等核心功能。从本章开始,我们将进入一个全新的阶段:将所学知识融会贯通,构建完整的实用应用。

还记得在第一章中,我们构建的第一个智能体吗?那是一个简单的智能旅行助手,展示了Thought-Action-Observation循环的基本原理。本章的智能旅行助手将是一个完整的项目,包含以下核心功能:

(1)智能行程规划:用户输入目的地、日期、偏好等信息,系统自动生成包含景点、餐饮、酒店的完整行程计划。

(2)地图可视化:在地图上标注景点位置、绘制游览路线,让行程一目了然。

(3)预算计算:自动计算门票、酒店、餐饮、交通费用,显示预算明细。

(4)行程编辑:支持添加、删除、调整景点,实时更新地图。

(5)导出功能:支持导出为PDF或图片,方便保存和分享。

13.1 项目概述与架构设计

13.1.1 为什么需要智能旅行助手

规划一次旅行是一件既令人兴奋又令人头疼的事情。你需要在网上搜索景点信息,对比不同的攻略,查看天气预报,预订酒店,计算预算,规划路线。这个过程可能需要花费几个小时甚至几天的时间。而且即使花了这么多时间,你也不确定规划的行程是否合理,是否遗漏了什么重要的景点,预算是否准确。

传统的旅行规划方式有几个痛点。首先是信息分散。景点信息在旅游网站上,天气信息在天气网站上,酒店信息在预订网站上,你需要在多个网站之间切换,手动整合这些信息。其次是缺少个性化。大部分攻略都是通用的,不考虑你的个人偏好、预算限制、出行时间等因素。最后是难以调整。当你想修改行程时,可能需要重新规划整个行程,因为景点的顺序、时间安排、预算都是相互关联的。

AI技术为解决这些问题提供了新的可能。想象一下,你只需要告诉系统"我想去北京玩3天,喜欢历史文化,预算中等",系统就能自动为你生成一个完整的行程计划,包括每天去哪些景点、在哪里吃饭、住哪个酒店、需要多少预算。而且这个计划是可以调整的,你可以删除不喜欢的景点,调整游览顺序,系统会自动更新地图和预算。

这就是我们要构建的智能旅行助手。它不仅仅是一个技术演示,而是一个真正有用的应用。通过这个项目,你会学到如何将AI技术应用到实际问题中,如何设计多智能体系统,如何构建完整的Web应用。

13.1.2 技术架构概览

系统采用经典的前后端分离架构,分为四个层次,如图13.1所示:

图 13.1 智能旅行助手技术架构

(1)前端层 (Vue3+TypeScript):负责用户交互和数据展示,包括表单输入、结果展示、地图可视化。

(2)后端层 (FastAPI):负责API路由、数据验证、业务逻辑。

(3)智能体层 (HelloAgents):负责任务分解、工具调用、结果整合。包含4个专门的Agent。

(4)外部服务层:提供数据和能力,包括高德地图API、Unsplash API、LLM API。

数据流转过程如下:用户在前端填写表单 → 后端验证数据 → 调用智能体系统 → 智能体依次调用景点搜索、天气查询、酒店推荐、行程规划Agent → 每个Agent通过MCP协议调用外部API → 整合结果返回前端 → 前端渲染展示。

项目的结构参考如下,提供便于定位源码:

helloagents-trip-planner/
├── backend/                    # 后端代码
│   ├── app/
│   │   ├── agents/            # 智能体实现
│   │   ├── api/               # API路由
│   │   ├── models/            # 数据模型
│   │   ├── services/          # 服务层
│   │   └── config.py          # 配置文件
│   └── requirements.txt       # Python依赖
│
└── frontend/                   # 前端代码
    ├── src/
    │   ├── views/             # 页面组件
    │   ├── services/          # API服务
    │   ├── types/             # 类型定义
    │   └── router/            # 路由配置
    └── package.json           # npm依赖

详细的架构设计和数据流转将在后续章节中介绍。

13.1.3 快速体验:5分钟运行项目

在深入学习实现细节之前,让我们先把项目跑起来,看看最终的效果。这样你会对整个系统有一个直观的认识。

环境要求:

  • Python 3.10或更高版本
  • Node.js 16.0或更高版本
  • npm 8.0或更高版本

获取API密钥:

你需要准备以下API密钥:

将所有API密钥放入.env文件。

启动后端:

# 1. 进入后端目录
cd helloagents-trip-planner/backend

# 2. 安装依赖
pip install -r requirements.txt

# 3. 配置环境变量
cp .env.example .env
# 编辑.env文件,填入你的API密钥

# 4. 启动后端服务
uvicorn app.api.main:app --reload
# 或者
python run.py

成功启动后,访问 http://localhost:8000/docs 可以看到API文档。

打开新的终端窗口:

# 1. 进入前端目录
cd helloagents-trip-planner/frontend

# 2. 安装依赖
npm install

# 3. 启动前端服务
npm run dev

成功启动后,访问 http://localhost:5173 即可使用应用。

体验核心功能:

首先需在首页表单中填写目的地城市、旅行日期、偏好、预算、交通及住宿类型等信息。点击“开始规划”按钮后,系统会显示加载进度条,并很快生成结果页面,如图13.2所示。

图 13.2 旅行助手规划进行页面

随后加载成功,该页面会清晰展示行程概览、预算明细、景点地图、每日行程详情和天气信息,如图13.3,13.4所示。

图 13.3 旅行助手规划完成页面

图 13.4 旅行助手规划完成页面

如果用户需要个性化调整,可以点击“编辑行程”按钮,自由调整景点顺序或删除某个景点,如图13.5所示。规划完成后,通过“导出行程”下拉菜单,即可将最终方案轻松保存为图片或PDF文件,方便随时查阅。

图 13.5 旅行助手规划完成页面

13.2 数据模型设计

13.2.1 Web应用中的数据流转

在构建智能旅行助手时,我们需要解决一个核心问题:如何表示和传递旅行计划数据?

我们需要理解一个完整的Web应用中数据是如何流转的。想象一下,当用户在浏览器中点击"开始规划"按钮时,会发生什么?

用户在前端填写的表单数据(目的地、日期、预算等)需要通过HTTP请求发送到后端服务器。后端接收到数据后,会调用智能体系统进行处理。智能体又会调用高德地图API、Unsplash API等外部服务获取数据。这些外部API返回的数据格式各不相同,有的用lng,有的用lon,有的用longitude。最后,后端需要将处理好的数据返回给前端,前端再渲染成用户看到的页面。

在这个过程中,数据经历了多次转换:前端表单 → HTTP请求 → 后端Python对象 → 外部API响应 → 后端Python对象 → HTTP响应 → 前端TypeScript对象 → 页面展示。如果没有统一的数据格式,每一步转换都可能出错。这就是为什么我们需要数据模型

13.2.2 从字典到Pydantic模型

让我们从第一章的简单原型开始。在那个原型中,我们使用Python字典来表示景点数据:

# 第一章的做法:使用字典
attraction = {
    "name": "故宫",
    "location": {"lng": 116.397128,"lat": 39.916527},
    "price": 60
}

# 访问数据
lng = attraction["location"]["lng"]

这种方式在原型阶段很方便,但在实际项目中会遇到很多问题。首先是字段名不统一的问题。高德地图API返回的位置数据是"116.397128,39.916527"这样的字符串,需要手动分割成经纬度。而Unsplash API可能使用longitudelatitude。如果我们在代码中到处都用字典,就需要在每个地方都处理这些差异。

其次是类型安全的问题。假设我们不小心把price写成了字符串"60",在Python中这不会立即报错,但在计算总预算时就会出问题。更糟糕的是,这种错误只能在运行时才能发现,而且错误信息可能很难定位。

最后是维护性的问题。当我们需要给景点添加新字段(比如rating评分)时,需要在代码的多个地方修改。如果遗漏了某个地方,就会导致数据不一致。

Pydantic提供了一个解决方案。它是Python的数据验证库,可以让我们用类来定义数据结构,并自动处理验证、转换和序列化。让我们看一个简单的例子:

from pydantic import BaseModel,Field

class Location(BaseModel):
    longitude: float = Field(...,description="经度")
    latitude: float = Field(...,description="纬度")

class Attraction(BaseModel):
    name: str
    location: Location
    ticket_price: int = 0

# 创建对象
attraction = Attraction(
    name="故宫",
    location=Location(longitude=116.397128,latitude=39.916527),
    ticket_price=60
)

# 类型安全的访问
lng = attraction.location.longitude  # IDE会提供代码补全

这样做有几个好处。首先,如果我们传入了错误的类型(比如把ticket_price设为字符串),Pydantic会立即抛出异常,告诉我们哪里出错了。其次,IDE可以根据类型定义提供代码补全和类型检查,大大减少了拼写错误。最后,当我们需要修改数据结构时,只需要修改类定义,所有使用这个类的地方都会自动更新。

13.2.3 Pydantic的核心概念

在深入设计我们的数据模型之前,让我们先了解Pydantic的几个核心概念。Pydantic的基础是BaseModel类,所有的数据模型都需要继承这个类。每个字段都可以指定类型,Pydantic会自动进行类型检查和转换。

字段定义使用Field函数,它可以指定默认值、描述、验证规则等。...表示这个字段是必填的,如果创建对象时没有提供这个字段,Pydantic会抛出异常。我们也可以使用Optional来表示可选字段,或者直接提供默认值。

from pydantic import BaseModel,Field
from typing import Optional,List

class Attraction(BaseModel):
    name: str = Field(...,description="景点名称")  # 必填
    rating: float = Field(default=0.0,ge=0,le=5)  # 默认值,范围验证
    visit_duration: int = Field(default=60,gt=0)  # 大于0
    description: Optional[str] = None  # 可选字段

Pydantic还支持嵌套模型和列表。我们可以在一个模型中使用另一个模型作为字段类型,这样就可以构建复杂的数据结构。比如,一个景点包含位置信息,一个行程包含多个景点。

class DayPlan(BaseModel):
    date: str
    attractions: List[Attraction]  # 景点列表
    hotel: Optional[Hotel] = None  # 可选的酒店信息

最强大的功能之一是自定义验证器。有时候外部API返回的数据格式不符合我们的要求,我们可以使用field_validator装饰器来自定义验证和转换逻辑。比如,高德地图返回的温度是"16°C"这样的字符串,我们需要把它转换成数字:

from pydantic import field_validator

class WeatherInfo(BaseModel):
    temperature: int
    
    @field_validator('temperature',mode='before')
    def parse_temperature(cls,v):
        """解析温度字符串:"16°C" -> 16"""
        if isinstance(v,str):
            v = v.replace('°C','').replace('℃','').strip()
            return int(v)
        return v

这个验证器会在创建对象之前自动执行,将字符串转换成整数。这样我们就不需要在代码的每个地方都手动处理温度格式了。

13.2.4 自底向上的模型设计

现在让我们开始设计智能旅行助手的数据模型。一个好的设计原则是自底向上:先定义最基础的模型,然后逐步组合成复杂的结构。这样做的好处是每个模型都很简单,容易理解和维护。

最基础的模型是位置信息。无论是景点、酒店还是餐厅,都需要位置信息。我们定义一个Location类来表示经纬度坐标:

class Location(BaseModel):
    """位置信息(经纬度坐标)"""
    longitude: float = Field(...,description="经度",ge=-180,le=180)
    latitude: float = Field(...,description="纬度",ge=-90,le=90)

这里我们使用了范围验证(ge表示大于等于,le表示小于等于),确保经纬度的值在合理范围内。

接下来是景点信息。一个景点包含名称、地址、位置、游览时间、描述、评分、图片和门票价格等信息。注意我们使用了Location作为字段类型,这就是嵌套模型:

class Attraction(BaseModel):
    """景点信息"""
    name: str = Field(...,description="景点名称")
    address: str = Field(...,description="地址")
    location: Location = Field(...,description="经纬度坐标")
    visit_duration: int = Field(...,description="建议游览时间(分钟)",gt=0)
    description: str = Field(...,description="景点描述")
    category: Optional[str] = Field(default="景点",description="景点类别")
    rating: Optional[float] = Field(default=None,ge=0,le=5,description="评分")
    image_url: Optional[str] = Field(default=None,description="图片URL")
    ticket_price: int = Field(default=0,ge=0,description="门票价格(元)")

类似地,我们定义餐饮信息酒店信息。这些模型的结构都很相似,都包含名称、地址、位置和费用等基本信息:

class Meal(BaseModel):
    """餐饮信息"""
    type: str = Field(...,description="餐饮类型:breakfast/lunch/dinner/snack")
    name: str = Field(...,description="餐饮名称")
    address: Optional[str] = Field(default=None,description="地址")
    location: Optional[Location] = Field(default=None,description="经纬度坐标")
    description: Optional[str] = Field(default=None,description="描述")
    estimated_cost: int = Field(default=0,description="预估费用(元)")

class Hotel(BaseModel):
    """酒店信息"""
    name: str = Field(...,description="酒店名称")
    address: str = Field(default="",description="酒店地址")
    location: Optional[Location] = Field(default=None,description="酒店位置")
    price_range: str = Field(default="",description="价格范围")
    rating: str = Field(default="",description="评分")
    distance: str = Field(default="",description="距离景点距离")
    type: str = Field(default="",description="酒店类型")
    estimated_cost: int = Field(default=0,description="预估费用(元/晚)")

预算信息是一个特殊的模型,它不包含位置信息,而是包含各项费用的汇总:

class Budget(BaseModel):
    """预算信息"""
    total_attractions: int = Field(default=0,description="景点门票总费用")
    total_hotels: int = Field(default=0,description="酒店总费用")
    total_meals: int = Field(default=0,description="餐饮总费用")
    total_transportation: int = Field(default=0,description="交通总费用")
    total: int = Field(default=0,description="总费用")

现在我们可以组合这些基础模型,构建单日行程。一个单日行程包含日期、描述、交通方式、住宿安排、酒店、景点列表和餐饮列表:

class DayPlan(BaseModel):
    """单日行程"""
    date: str = Field(...,description="日期")
    day_index: int = Field(...,description="第几天(从0开始)")
    description: str = Field(...,description="当日行程描述")
    transportation: str = Field(...,description="交通方式")
    accommodation: str = Field(...,description="住宿安排")
    hotel: Optional[Hotel] = Field(default=None,description="酒店信息")
    attractions: List[Attraction] = Field(default_factory=list,description="景点列表")
    meals: List[Meal] = Field(default_factory=list,description="餐饮安排")

注意这里使用了List[Attraction]来表示景点列表,default_factory=list表示默认值是一个空列表。

天气信息需要特殊处理,因为高德地图返回的温度格式不规范。我们使用自定义验证器来处理:

class WeatherInfo(BaseModel):
    """天气信息"""
    date: str = Field(...,description="日期")
    day_weather: str = Field(...,description="白天天气")
    night_weather: str = Field(...,description="夜间天气")
    day_temp: int = Field(...,description="白天温度(摄氏度)")
    night_temp: int = Field(...,description="夜间温度(摄氏度)")
    wind_direction: str = Field(...,description="风向")
    wind_power: str = Field(...,description="风力")
    
    @field_validator('day_temp','night_temp',mode='before')
    def parse_temperature(cls,v):
        """解析温度字符串:"16°C" -> 16"""
        if isinstance(v,str):
            v = v.replace('°C','').replace('℃','').replace('°','').strip()
            try:
                return int(v)
            except ValueError:
                return 0  # 容错处理
        return v

最后,我们定义完整的旅行计划。这是最顶层的模型,包含了所有的信息:

class TripPlan(BaseModel):
    """旅行计划"""
    city: str = Field(...,description="目的地城市")
    start_date: str = Field(...,description="开始日期")
    end_date: str = Field(...,description="结束日期")
    days: List[DayPlan] = Field(default_factory=list,description="每日行程")
    weather_info: List[WeatherInfo] = Field(default_factory=list,description="天气信息")
    overall_suggestions: str = Field(...,description="总体建议")
    budget: Optional[Budget] = Field(default=None,description="预算信息")

这样,我们就完成了整个数据模型的设计。从最基础的Location,到AttractionMealHotel,再到DayPlan,最后到TripPlan,形成了一个清晰的层次结构。

13.2.5 数据模型在Web应用中的应用

现在让我们看看这些数据模型如何在实际的Web应用中使用。在FastAPI中,Pydantic模型可以直接用作请求和响应的类型定义。FastAPI会自动进行数据验证、序列化和文档生成。

from fastapi import FastAPI
from app.models.schemas import TripPlanRequest,TripPlan

app = FastAPI()

@app.post("/api/trip/plan",response_model=TripPlan)
async def create_trip_plan(request: TripPlanRequest) -> TripPlan:
    """
    创建旅行计划
    
    FastAPI自动:
    1. 验证请求数据(TripPlanRequest)
    2. 验证响应数据(TripPlan)
    3. 生成OpenAPI文档
    """
    trip_plan = await generate_trip_plan(request)
    return trip_plan

当用户发送POST请求到/api/trip/plan时,FastAPI会自动将JSON数据转换成TripPlanRequest对象。如果数据格式不正确(比如缺少必填字段,或者类型不匹配),FastAPI会自动返回400错误,并告诉用户哪里出错了。

在前端,我们也需要定义对应的TypeScript类型。虽然TypeScript和Python是不同的语言,但数据结构是一样的:

interface Location {
  longitude: number;
  latitude: number;
}

interface Attraction {
  name: string;
  address: string;
  location: Location;
  visit_duration: number;
  ticket_price: number;
}

interface TripPlan {
  city: string;
  start_date: string;
  end_date: string;
  days: DayPlan[];
}

这样,前后端就使用了统一的数据格式。当后端返回TripPlan对象时,前端可以直接使用,不需要任何转换。TypeScript的类型检查也能帮助我们避免很多错误。

13.3 多智能体协作设计

13.3.1 为何需要多智能体

在第七章中,我们学习了如何使用SimpleAgent来构建智能体。SimpleAgent的设计理念是简单直接:每次调用run()方法时,Agent会分析用户的问题,决定是否需要调用工具,然后返回结果。这种设计在处理简单任务时非常有效,但当面对旅行规划这样的任务时,就会遇到一些问题。

如果用单个Agent来完成旅行规划。这个Agent需要做什么呢?首先,它要搜索景点信息,这需要调用高德地图的POI搜索工具。然后,它要查询天气信息,这需要调用天气查询工具。接着,它要搜索酒店信息,这又需要调用POI搜索工具。最后,它要把所有这些信息整合起来,生成一个完整的旅行计划。

这听起来很简单,但实际操作时会遇到第一个问题:工具调用的限制。SimpleAgent每次run()调用只能执行一个工具。这意味着我们需要多次调用run()方法,每次调用处理一个任务。但这样做会带来一个新问题:如何在多次调用之间传递信息?第一次调用得到的景点信息,如何传递给第二次调用?我们需要手动管理这些中间结果,代码会变得很复杂。

当然,我们可以使用ReactAgent来解决这个问题。ReactAgent可以在一次调用中执行多个工具,它会自动进行多轮思考和行动。但这又带来了新的问题:时间成本。ReactAgent的每一轮思考都需要调用LLM,如果需要调用三个工具,就需要至少三轮思考,这意味着至少三次LLM调用。而且这些调用是串行的,必须等前一个完成才能开始下一个,总时间会很长。

第二个问题是提示词的复杂度。如果我们要让一个Agent完成所有任务,就需要在提示词中详细描述每个任务的执行逻辑。比如:

COMPLEX_PROMPT = """你是旅行规划助手。你需要:
1. 使用maps_text_search搜索景点,关键词根据用户偏好确定
2. 使用maps_weather查询天气,获取未来几天的天气预报
3. 使用maps_text_search搜索酒店,类型根据用户需求确定
4. 整合所有信息生成旅行计划,包括每天的景点、餐饮、住宿安排
注意:必须按顺序执行,每个工具只能调用一次,输出必须是JSON格式...
"""

这样的提示词有几个问题。首先是难以维护。如果我们想修改景点搜索的逻辑(比如增加评分筛选),就需要修改整个提示词,很容易影响到其他部分。其次是容易出错。LLM需要同时理解多个任务的要求,很容易搞混不同任务的格式和参数。最后是难以调试。当生成的计划不符合预期时,我们很难知道是哪个环节出了问题,是景点搜索不准确,还是天气查询失败,还是整合逻辑有问题?

面对这些问题,一个自然的想法是:能不能把复杂的任务分解成多个简单的任务,让不同的Agent各司其职?这就是多Agent协作的核心思想。

想象一下现实世界中的旅行社。当你去旅行社咨询旅行计划时,不会只有一个人为你服务。通常会有专门的景点顾问,负责推荐景点;有酒店顾问,负责预订酒店;还有行程规划师,负责把所有信息整合成完整的行程。每个人都专注于自己擅长的领域,最后由行程规划师把所有信息汇总。这种分工协作的方式,比让一个人做所有事情要高效得多。

13.3.2 Agent角色设计

基于任务分解原则,我们设计了四个专门的Agent,如图13.6所示:

图 13.6 多智能体协作流程

  • AttractionSearchAgent(景点搜索专家)专注于搜索景点信息。它只需要理解用户的偏好(比如"历史文化"、"自然风光"),然后调用高德地图的POI搜索工具,返回相关的景点列表。它的提示词很简单,只需要说明如何根据偏好选择关键词,如何调用工具。

  • WeatherQueryAgent(天气查询专家)专注于查询天气信息。它只需要知道城市名称,然后调用天气查询工具,返回未来几天的天气预报。它的任务非常明确,几乎不会出错。

  • HotelAgent(酒店推荐专家)专注于搜索酒店信息。它需要理解用户的住宿需求(比如"经济型"、"豪华型"),然后调用POI搜索工具,返回符合要求的酒店列表。

  • PlannerAgent(行程规划专家)负责整合所有信息。它接收前三个Agent的输出,加上用户的原始需求(日期、预算等),然后生成完整的旅行计划。它不需要调用任何外部工具,只需要专注于信息的整合和行程的安排。

现在让我们详细设计每个Agent的角色和提示词。设计提示词时,我们需要考虑几个关键问题:这个Agent需要什么输入?它应该产生什么输出?它需要调用什么工具?它可能遇到什么问题?

AttractionSearchAgent的任务是根据用户偏好搜索景点。它的输入是城市名称和用户偏好(比如"历史文化"、"自然风光")。它需要调用amap_maps_text_search工具,参数是关键词和城市。它的输出是景点列表,包含名称、地址、评分等信息。

ATTRACTION_AGENT_PROMPT = """你是景点搜索专家。

**工具调用格式:**
`[TOOL_CALL:amap_maps_text_search:keywords=景点,city=城市名]`

**示例:**
- `[TOOL_CALL:amap_maps_text_search:keywords=景点,city=北京]`
- `[TOOL_CALL:amap_maps_text_search:keywords=博物馆,city=上海]`

**重要:**
- 必须使用工具搜索,不要编造信息
- 根据用户偏好({preferences})搜索{city}的景点
"""

这个提示词很简洁,但包含了所有必要的信息。它明确说明了工具调用的格式,提供了具体的示例,还强调了两个重要原则:必须使用工具(不能编造),要根据用户偏好搜索。

WeatherQueryAgent的任务更简单,只需要查询天气。它的输入是城市名称,输出是天气信息。

WEATHER_AGENT_PROMPT = """你是天气查询专家。

**工具调用格式:**
`[TOOL_CALL:amap_maps_weather:city=城市名]`

请查询{city}的天气信息。
"""

HotelAgent的任务是搜索酒店。它的输入是城市名称和住宿类型,输出是酒店列表。

HOTEL_AGENT_PROMPT = """你是酒店推荐专家。

**工具调用格式:**
`[TOOL_CALL:amap_maps_text_search:keywords=酒店,city=城市名]`

请搜索{city}的{accommodation}酒店。
"""

PlannerAgent是最复杂的,因为它需要整合所有信息。它的输入是用户需求和前三个Agent的输出,输出是完整的旅行计划(JSON格式)。

PLANNER_AGENT_PROMPT = """你是行程规划专家。

**输出格式:**
严格按照以下JSON格式返回:
{
  "city": "城市名称",
  "start_date": "YYYY-MM-DD",
  "end_date": "YYYY-MM-DD",
  "days": [...],
  "weather_info": [...],
  "overall_suggestions": "总体建议",
  "budget": {...}
}

**规划要求:**
1. weather_info必须包含每天的天气
2. 温度为纯数字(不带°C)
3. 每天安排2-3个景点
4. 考虑景点距离和游览时间
5. 包含早中晚三餐
6. 提供实用建议
7. 包含预算信息
"""

13.3.3 Agent协作流程

现在让我们看看这四个Agent如何协作完成旅行规划任务。整个流程可以分为五个步骤:

class TripPlannerAgent:
    def __init__(self):
        self.attraction_agent = SimpleAgent(name="景点搜索"prompt=ATTRACTION_PROMPT)
        self.weather_agent = SimpleAgent(name="天气查询", prompt=WEATHER_PROMPT)
        self.hotel_agent = SimpleAgent(name="酒店推荐", prompt=HOTEL_PROMPT)
        self.planner_agent = SimpleAgent(name="行程规划", prompt=PLANNER_PROMPT)

    def plan_trip(self, request: TripPlanRequest) -> TripPlan:
        # 步骤1: 景点搜索
        attraction_response = self.attraction_agent.run(
            f"请搜索{request.city}的{request.preferences}景点"
        )

        # 步骤2: 天气查询
        weather_response = self.weather_agent.run(
            f"请查询{request.city}的天气"
        )

        # 步骤3: 酒店推荐
        hotel_response = self.hotel_agent.run(
            f"请搜索{request.city}的{request.accommodation}酒店"
        )

        # 步骤4: 整合生成计划
        planner_query = self._build_planner_query(
            request, attraction_response, weather_response, hotel_response
        )
        planner_response = self.planner_agent.run(planner_query)

        # 步骤5: 解析JSON
        trip_plan = self._parse_trip_plan(planner_response)
        return trip_plan

这个流程顺序执行四个步骤,每个步骤的输出作为下一个步骤的输入。注意我们使用了TripPlanRequestTripPlan这两个Pydantic模型,这是在13.2节中定义的。

13.3.4 查询构建

PlannerAgent需要整合所有信息,这个查询需要包含所有必要的信息,而且要组织得清晰有序,让LLM能够准确理解。

def _build_planner_query(
    self,
    request: TripPlanRequest,
    attraction_response: str,
    weather_response: str,
    hotel_response: str
) -> str:
    """构建规划Agent的查询"""
    return f"""
请根据以下信息生成{request.city}的{request.days}日旅行计划:

**用户需求:**
- 目的地: {request.city}
- 日期: {request.start_date} 至 {request.end_date}
- 天数: {request.days}天
- 偏好: {request.preferences}
- 预算: {request.budget}
- 交通方式: {request.transportation}
- 住宿类型: {request.accommodation}

**景点信息:**
{attraction_response}

**天气信息:**
{weather_response}

**酒店信息:**
{hotel_response}

请生成详细的旅行计划,包括每天的景点安排、餐饮推荐、住宿信息和预算明细。
"""

通过这种多Agent协作的设计,我们把一个复杂的旅行规划任务分解成了四个简单的子任务。每个Agent都专注于自己擅长的领域,也为未来的功能扩展(比如添加餐厅推荐Agent、交通规划Agent)打下了良好的基础。

13.4 MCP工具集成详解

13.4.1 为什么不直接调用API

在13.3节中,我们设计了四个Agent来协作完成旅行规划任务。其中AttractionSearchAgent、WeatherQueryAgent和HotelAgent都需要调用高德地图的API来获取数据。一个自然的问题是:为什么不直接在Agent中调用高德地图的HTTP API?

让我们先看看直接调用API会是什么样子。高德地图提供了POI搜索API,我们需要构造HTTP请求,传递参数,解析响应:

import requests

def search_poi(keywords: str,city: str,api_key: str):
    """直接调用高德地图POI搜索API"""
    url = "https://restapi.amap.com/v3/place/text"
    params = {
        "keywords": keywords,
        "city": city,
        "key": api_key,
        "output": "json"
    }
    response = requests.get(url,params=params)
    data = response.json()
    return data

这种方式看起来很简单,但在实际使用中会遇到几个问题。首先是Agent无法自主调用。在我们的HelloAgents框架中,Agent通过识别提示词中的工具调用标记(比如[TOOL_CALL:tool_name:arg1=value1])来调用工具。如果我们直接在代码中调用API,Agent就失去了自主决策的能力,变成了一个简单的函数调用。

其次是参数传递复杂。高德地图的API有很多参数,比如POI搜索有keywordscitytypesoffsetpage等十几个参数。如果我们要让Agent能够灵活使用这些参数,就需要在提示词中详细说明每个参数的含义和格式,这会让提示词变得非常复杂。

第三是响应解析困难。高德地图API返回的是JSON格式的数据,结构比较复杂。我们需要编写代码来解析这些数据,提取我们需要的字段。如果API的响应格式发生变化,我们就需要修改解析代码。

最后是工具管理混乱。高德地图提供了十几个不同的API(POI搜索、天气查询、路线规划等),如果我们为每个API都编写一个函数,然后手动注册到Agent的工具列表中,代码会变得很冗长。而且当我们想添加新的API时,需要修改多个地方。

13.4.2 高德地图MCP集成

MCP(Model Context Protocol)是Anthropic提出的标准化协议,用于连接LLM和外部工具。本节将介绍如何在项目中集成高德地图MCP服务器。我们的项目用的是amap-mcp-server,这是一个用Node.js实现的MCP服务器:

图 13.7 amap-mcp-server工具

高德地图MCP服务器提供了多种工具,主要分为以下类别,如表13.1所示:

表 13.1 高德地图MCP工具分类

通过MCP协议,我们可以很方便地在HelloAgents中集成:

from hello_agents.tools import MCPTool
from app.config import get_settings

settings = get_settings()

# 创建MCP工具
mcp_tool = MCPTool(
    name="amap_mcp",
    command="npx",
    args=["-y", "@sugarforever/amap-mcp-server"],
    env={"AMAP_API_KEY": settings.amap_api_key},
    auto_expand=True
)

这段代码做了什么呢?首先,commandargs指定了如何启动MCP服务器。npx -y @sugarforever/amap-mcp-server会从npm仓库下载并运行amap-mcp-server这个包。env参数传递了环境变量,这里我们传递了高德地图的API密钥。

当我们创建MCPTool对象时,它会在后台启动MCP服务器进程,并通过标准输入输出(stdin/stdout)与服务器通信。这是MCP协议的一个特点:使用进程间通信而不是HTTP,这样更高效,也更容易管理。

最关键的是auto_expand=True这个参数。当设置为True时,MCPTool会自动查询MCP服务器提供了哪些工具,然后为每个工具创建一个独立的Tool对象。这就是为什么我们只创建了一个MCPTool,但Agent却获得了16个工具。让我们看看这个过程:

# 创建一个MCPTool
mcp_tool = MCPTool(..., auto_expand=True)
agent.add_tool(mcp_tool)

# Agent实际上获得了16个工具!
print(list(agent.tools.keys()))
# ['amap_maps_text_search', 'amap_maps_weather', ...]

如图13.8所示,假设用户想搜索北京的景点,AttractionSearchAgent接收到查询"请搜索北京的历史文化景点"。Agent分析这个查询,决定调用amap_maps_text_search工具,参数是keywords=景点,city=北京

图 13.8 MCP工具调用流程

Agent生成工具调用标记:[TOOL_CALL:amap_maps_text_search:keywords=景点,city=北京]。HelloAgents框架解析这个标记,提取工具名称和参数,然后调用对应的Tool对象。

Tool对象是MCPTool自动创建的,它会把调用请求发送给MCP服务器。具体来说,它会构造一个JSON-RPC格式的消息,通过stdin发送给服务器进程:

{
  "jsonrpc": "2.0",
  "method": "tools/call",
  "params": {
    "name": "amap_maps_text_search",
    "arguments": {
      "keywords": "景点",
      "city": "北京"
    }
  }
}

MCP服务器接收到这个消息,解析参数,然后调用高德地图的HTTP API。它会构造HTTP请求,添加API密钥,发送请求,接收响应。

高德地图API返回JSON格式的数据,包含景点列表、地址、坐标等信息。MCP服务器解析这些数据,提取关键字段,然后构造响应消息,通过stdout返回给MCPTool

{
  "jsonrpc": "2.0",
  "result": {
    "content": [
      {
        "type": "text",
        "text": "找到以下景点:\n1. 故宫博物院 - 地址:东城区景山前街4号\n2. 天坛公园 - 地址:东城区天坛路\n..."
      }
    ]
  }
}

MCPTool接收到响应,提取文本内容,返回给Agent。Agent把这个结果作为工具调用的输出,继续生成最终的回复。

这个流程看起来很复杂,但对于Agent来说,它只需要知道有一个叫amap_maps_text_search的工具,可以搜索景点。所有的底层细节都被MCP协议和MCPTool封装起来了。

13.4.3 共享MCP实例

在我们的多Agent系统中,有三个Agent都需要使用高德地图的工具。那么每个Agent应该创建自己的MCPTool实例,还是共享同一个实例?

如果每个Agent都创建一个MCPTool实例,这意味着会有三个服务器进程同时运行。每个进程都会独立地调用高德地图API,这可能会超过API的速率限制。而且多个进程会占用更多的内存和CPU资源。

更好的做法是让所有Agent共享同一个MCPTool实例。这样只需要启动一个MCP服务器进程,所有的API调用都通过这个进程进行。这不仅节省资源,还可以更好地控制API调用频率。

在代码中,我们在TripPlannerAgent的构造函数中创建一个MCPTool实例,然后把它添加到每个子Agent的工具列表中:

class TripPlannerAgent:
    def __init__(self):
        settings = get_settings()
        self.llm = HelloAgentsLLM()

        # 创建共享的MCP工具实例(只创建一次)
        self.mcp_tool = MCPTool(
            name="amap_mcp",
            command="npx",
            args=["-y", "@sugarforever/amap-mcp-server"],
            env={"AMAP_API_KEY": settings.amap_api_key},
            auto_expand=True
        )

        # 创建多个Agent,共享同一个MCP工具
        self.attraction_agent = SimpleAgent(
            name="AttractionSearchAgent",
            llm=self.llm,
            system_prompt=ATTRACTION_AGENT_PROMPT
        )
        self.attraction_agent.add_tool(self.mcp_tool)  # 共享

        self.weather_agent = SimpleAgent(
            name="WeatherQueryAgent",
            llm=self.llm,
            system_prompt=WEATHER_AGENT_PROMPT
        )
        self.weather_agent.add_tool(self.mcp_tool)  # 共享

        self.hotel_agent = SimpleAgent(
            name="HotelAgent",
            llm=self.llm,
            system_prompt=HOTEL_AGENT_PROMPT
        )
        self.hotel_agent.add_tool(self.mcp_tool)  # 共享

这样,三个Agent都可以使用高德地图的16个工具,但底层只有一个MCP服务器进程在运行。当我们调用TripPlannerAgentplan_trip方法时,三个Agent会依次调用工具,所有的请求都通过同一个MCP服务器发送到高德地图API。

13.4.4 Unsplash图片API集成

除了高德地图,我们还需要为景点获取图片,让旅行计划更加生动直观。我们使用Unsplash API来搜索景点图片。需要注意的是,Unsplash是国外的服务,而且是为数不多可以免费使用的图片API,所以搜索结果可能不够准确。在实际项目中,可以考虑使用必应、百度或高德的POI图片API,但这些服务通常需要付费。

Unsplash API的集成比较简单,我们创建一个UnsplashService类来封装API调用:

# backend/app/services/unsplash_service.py
import requests
from typing import Optional, List, Dict
import logging

logger = logging.getLogger(__name__)

class UnsplashService:
    """Unsplash图片服务"""

    def __init__(self, access_key: str):
        self.access_key = access_key
        self.base_url = "https://api.unsplash.com"

    def search_photos(self, query: str, per_page: int = 10) -> List[Dict]:
        """搜索图片"""
        try:
            url = f"{self.base_url}/search/photos"
            params = {
                "query": query,
                "per_page": per_page,
                "client_id": self.access_key
            }

            response = requests.get(url, params=params, timeout=10)
            response.raise_for_status()

            data = response.json()
            results = data.get("results", [])

            # 提取图片URL
            photos = []
            for result in results:
                photos.append({
                    "url": result["urls"]["regular"],
                    "description": result.get("description", ""),
                    "photographer": result["user"]["name"]
                })

            return photos

        except Exception as e:
            logger.error(f"搜索图片失败: {e}")
            return []

    def get_photo_url(self, query: str) -> Optional[str]:
        """获取单张图片URL"""
        photos = self.search_photos(query, per_page=1)
        return photos[0].get("url") if photos else None

这个服务类提供了两个方法:search_photos搜索多张图片,get_photo_url获取单张图片的URL。我们在API路由中使用这个服务,为每个景点获取图片:

# backend/app/api/routes/trip.py
from app.services.unsplash_service import UnsplashService

unsplash_service = UnsplashService(settings.unsplash_access_key)

@router.post("/plan", response_model=TripPlan)
async def create_trip_plan(request: TripPlanRequest) -> TripPlan:
    # 生成旅行计划
    trip_plan = trip_planner_agent.plan_trip(request)

    # 为每个景点获取图片
    for day in trip_plan.days:
        for attraction in day.attractions:
            if not attraction.image_url:
                image_url = unsplash_service.get_photo_url(
                    f"{attraction.name} {trip_plan.city}"
                )
                attraction.image_url = image_url

    return trip_plan

注意我们没有把Unsplash封装成Tool或MCP工具,而是直接在API路由中调用。这是因为图片搜索不需要Agent的智能决策,只是一个简单的数据增强步骤。如果你想让Agent能够自主决定是否需要图片,或者选择不同的图片来源,可以考虑把它封装成Tool。

13.5 前端开发详解

13.5.1 前后端分离的Web架构

在开始前端开发之前,我们需要理解现代Web应用的架构模式。在早期的Web开发中,前端和后端是混在一起的,比如PHP、JSP这样的技术,HTML模板和业务逻辑代码写在同一个文件里。这种方式在小项目中很方便,但在大型项目中会遇到很多问题:前端和后端开发者需要频繁协调,代码难以复用,测试困难。

现代Web应用普遍采用前后端分离的架构。后端只负责提供API接口,返回JSON格式的数据。前端是一个独立的应用,通过HTTP请求调用后端API,获取数据后渲染页面。这种架构有几个明显的优势:前端和后端可以独立开发、独立部署、独立测试;前端可以是Web应用、移动应用或桌面应用,都使用同一套后端API;前端可以使用现代的框架和工具链,提供更好的用户体验。

在我们的智能旅行助手项目中,后端是用Python和FastAPI实现的,提供了一个核心API接口POST /api/trip/plan,接收旅行需求,返回旅行计划。前端是用Vue 3和TypeScript实现的,是一个单页应用(SPA),用户在浏览器中填写表单,点击"开始规划"按钮,前端发送HTTP请求到后端,等待响应,然后渲染结果页面。整个过程中,页面不会刷新,用户体验很流畅。

前端技术栈的选择需要考虑几个因素:开发效率、性能、生态系统、学习曲线。如表13.2所示,该项目选择了以下技术栈:

表 13.2 前端技术栈

项目的目录结构是这样的:

frontend/
├── src/
│   ├── views/              # 页面组件
│   │   ├── Home.vue        # 首页(表单)
│   │   └── Result.vue      # 结果页
│   ├── services/           # API服务
│   │   └── api.ts
│   ├── types/              # 类型定义
│   │   └── index.ts
│   ├── router/             # 路由配置
│   │   └── index.ts
│   ├── App.vue
│   └── main.ts
├── package.json
├── vite.config.ts
└── tsconfig.json

其中views目录存放页面组件,services目录存放API调用逻辑,types目录存放TypeScript类型定义,router目录存放路由配置。

13.5.2 类型定义

在13.2节中,我们在后端使用Pydantic定义了数据模型,比如LocationAttractionDayPlanTripPlan等。在前端,我们需要定义对应的TypeScript类型。

让我们看看如何定义这些类型。首先是最基础的Location类型,表示经纬度坐标:

// frontend/src/types/index.ts
export interface Location {
  longitude: number
  latitude: number
}

这个类型定义和后端的Pydantic模型完全对应。注意TypeScript使用interface关键字定义类型,字段类型用冒号分隔,不需要默认值。

接下来是Attraction类型,表示景点信息:

export interface Attraction {
  name: string
  address: string
  location: Location
  visit_duration: number
  description: string
  category?: string
  rating?: number
  image_url?: string
  ticket_price?: number
}

注意这里使用了Location类型作为字段类型,这就是嵌套类型。问号?表示可选字段,对应后端Pydantic模型中的Optional

类似地,我们定义MealHotelBudgetWeatherInfo等类型。最后是顶层的TripPlan类型:

export interface TripPlan {
  city: string
  start_date: string
  end_date: string
  days: DayPlan[]
  weather_info: WeatherInfo[]
  overall_suggestions: string
  budget?: Budget
}

还有请求类型TripPlanRequest,对应后端的请求模型:

export interface TripPlanRequest {
  city: string
  start_date: string
  end_date: string
  days: number
  preferences: string
  budget: string
  transportation: string
  accommodation: string
}

这些类型定义有什么用呢?首先,当我们调用API时,TypeScript会检查我们传递的数据是否符合TripPlanRequest类型。如果我们不小心把days写成了字符串,TypeScript会立即报错。其次,当我们接收API响应时,TypeScript会检查响应数据是否符合TripPlan类型。如果后端返回的数据结构发生变化,前端会立即发现。最后,IDE可以根据类型定义提供代码补全,我们输入tripPlan.时,IDE会自动列出所有可用的字段。

13.5.3 API服务封装

有了类型定义,我们就可以封装API调用了。我们创建一个api.ts文件,使用Axios来发送HTTP请求:

import axios from 'axios'
import type { TripPlanRequest,TripPlan } from '../types'

const api = axios.create({
  baseURL: 'http://localhost:8000/api',
  timeout: 120000, // 2分钟超时
  headers: {
    'Content-Type': 'application/json'
  }
})

这里我们创建了一个Axios实例,配置了基础URL、超时时间和请求头。为什么超时时间设置为2分钟?因为生成旅行计划需要调用多个Agent,每个Agent都要调用LLM和外部API,整个过程可能需要10-30秒。如果超时时间太短,请求会被中断。

接下来我们添加拦截器。拦截器可以在请求发送前和响应接收后执行一些通用逻辑,比如日志记录、错误处理、认证等:

// 请求拦截器
api.interceptors.request.use(
  config => {
    console.log('发送请求:',config)
    return config
  },
  error => Promise.reject(error)
)

// 响应拦截器
api.interceptors.response.use(
  response => {
    console.log('收到响应:',response)
    return response
  },
  error => {
    console.error('请求失败:',error)
    return Promise.reject(error)
  }
)

最后我们定义API函数,这是前端调用后端的唯一入口:

// 生成旅行计划
export const generateTripPlan = async (request: TripPlanRequest): Promise<TripPlan> => {
  const response = await api.post<TripPlan>('/trip/plan',request)
  return response.data
}

注意这个函数的类型签名:参数是TripPlanRequest类型,返回值是Promise<TripPlan>类型。这意味着TypeScript会检查调用者传递的参数是否符合要求,也会检查返回值的使用是否正确。

13.5.4 Home表单设计

Home页面是用户的入口,包含一个表单,让用户填写旅行需求。我们使用Vue 3的Composition API来组织代码:

<script setup lang="ts">
import { ref } from 'vue'
import { useRouter } from 'vue-router'
import { message } from 'ant-design-vue'
import { generateTripPlan } from '@/services/api'
import type { TripPlanRequest } from '@/types'

const router = useRouter()
const loading = ref(false)
const loadingProgress = ref(0)
const loadingStatus = ref('')

const formData = ref<TripPlanRequest>({
  city: '',
  start_date: '',
  end_date: '',
  days: 3,
  preferences: '历史文化',
  budget: '中等',
  transportation: '公共交通',
  accommodation: '经济型酒店'
})
</script>

这里我们使用ref来创建响应式变量。formData是表单数据,类型是TripPlanRequestloading表示是否正在加载,loadingProgress表示加载进度,loadingStatus表示加载状态文本。

表单提交的逻辑是这样的:

const handleSubmit = async () => {
  loading.value = true
  loadingProgress.value = 0
  
  // 模拟进度更新
  const progressInterval = setInterval(() => {
    if (loadingProgress.value < 90) {
      loadingProgress.value += 10
      if (loadingProgress.value <= 30) loadingStatus.value = '🔍 正在搜索景点...'
      else if (loadingProgress.value <= 50) loadingStatus.value = '🌤️ 正在查询天气...'
      else if (loadingProgress.value <= 70) loadingStatus.value = '🏨 正在推荐酒店...'
      else loadingStatus.value = '📋 正在生成行程计划...'
    }
  },500)
  
  try {
    const response = await generateTripPlan(formData.value)
    clearInterval(progressInterval)
    loadingProgress.value = 100
    router.push({ name: 'result',state: { tripPlan: response } })
  } catch (error) {
    clearInterval(progressInterval)
    message.error('生成计划失败,请重试')
  } finally {
    loading.value = false
  }
}

这段代码做了几件事。首先,设置loading为true,显示加载状态。然后,启动一个定时器,每500毫秒更新一次进度条和状态文本。这是一个模拟的进度,因为我们无法准确知道后端的处理进度。但这样可以让用户知道系统正在工作,而不是卡住了。

接着,调用generateTripPlan函数发送API请求。这是一个异步操作,我们使用await等待响应。如果请求成功,清除定时器,设置进度为100%,然后跳转到结果页面,并把旅行计划数据传递过去。如果请求失败,显示错误消息。最后,无论成功还是失败,都设置loading为false,隐藏加载状态。

模板部分使用Ant Design Vue的组件:

<template>
  <div class="home-container">
    <div class="page-header">
      <h1 class="page-title">✈️ 智能旅行助手</h1>
      <p class="page-subtitle">基于AI的个性化旅行规划</p>
    </div>
    
    <a-card class="form-card">
      <a-form :model="formData" @finish="handleSubmit">
        <a-form-item label="目的地城市" name="city" :rules="[{ required: true }]">
          <a-input v-model:value="formData.city" placeholder="如:北京" />
        </a-form-item>
        
        <!-- 更多表单项... -->
        
        <a-form-item>
          <a-button type="primary" html-type="submit" size="large" :loading="loading">
            开始规划
          </a-button>
        </a-form-item>
        
        <!-- 加载进度条 -->
        <a-form-item v-if="loading">
          <a-progress :percent="loadingProgress" status="active" />
          <p>{{ loadingStatus }}</p>
        </a-form-item>
      </a-form>
    </a-card>
  </div>
</template>

注意v-model:value指令,它实现了双向数据绑定。当用户在输入框中输入内容时,formData.city会自动更新。当formData.city的值改变时,输入框的内容也会自动更新。

13.5.5 Result页面展示

Result页面是整个应用的核心,展示生成的旅行计划。这个页面包含几个部分:行程概览、预算明细、地图可视化、每日行程详情、天气信息。

首先是地图可视化。我们使用高德地图JS API在地图上标注景点位置:

import AMapLoader from '@amap/amap-jsapi-loader'

const initMap = async () => {
  const AMap = await AMapLoader.load({
    key: 'your_amap_web_key',
    version: '2.0'
  })
  
  map = new AMap.Map('amap-container',{
    zoom: 12,
    center: [116.397128,39.916527]
  })
  
  // 添加景点标记
  tripPlan.value.days.forEach((day) => {
    day.attractions.forEach((attraction,index) => {
      const marker = new AMap.Marker({
        position: [attraction.location.longitude,attraction.location.latitude],
        title: attraction.name,
        label: { content: `${index + 1}`,direction: 'top' }
      })
      map.add(marker)
    })
  })
}

这段代码首先加载高德地图SDK,然后创建地图实例,最后遍历所有景点,为每个景点创建一个标记(Marker)。标记的位置是景点的经纬度坐标,这些坐标是从后端的Attraction对象中获取的。

导出功能使用html2canvasjsPDF库。html2canvas可以把DOM元素转换成Canvas,然后我们可以把Canvas导出为图片或PDF:

import html2canvas from 'html2canvas'
import jsPDF from 'jspdf'

// 导出为图片
const exportAsImage = async () => {
  const element = document.getElementById('trip-plan-content')
  const canvas = await html2canvas(element,{ scale: 2 })
  const link = document.createElement('a')
  link.download = `${tripPlan.value.city}旅行计划.png`
  link.href = canvas.toDataURL()
  link.click()
}

// 导出为PDF
const exportAsPDF = async () => {
  const element = document.getElementById('trip-plan-content')
  const canvas = await html2canvas(element,{ scale: 2 })
  const imgData = canvas.toDataURL('image/png')
  const pdf = new jsPDF('p','mm','a4')
  const imgWidth = 210
  const imgHeight = (canvas.height * imgWidth) / canvas.width
  pdf.addImage(imgData,'PNG',0,0,imgWidth,imgHeight)
  pdf.save(`${tripPlan.value.city}旅行计划.pdf`)
}

通过这些前端技术,我们实现了一个完整的Web应用。用户可以在浏览器中填写表单,提交请求,等待AI生成旅行计划,然后查看详细的行程安排,在地图上看到景点位置,还可以导出为图片或PDF。整个过程流畅自然,这就是现代Web应用的魅力。

13.6 功能实现详解

本节介绍智能旅行助手的核心功能实现,包括预算计算、加载进度条、行程编辑、导出功能和侧边导航。

13.6.1 预算计算功能

在规划旅行时,预算是一个非常重要的考虑因素。用户需要知道这次旅行大概要花多少钱,钱都花在哪里。我们的智能旅行助手提供了自动预算计算功能,将费用分为四大类:景点门票、酒店住宿、餐饮和交通。

预算计算的逻辑在哪里实现呢?我们选择在后端的PlannerAgent中实现。为什么不在前端计算?因为预算的估算需要基于景点的门票价格、酒店的价格范围、餐饮的标准等信息,这些信息都是PlannerAgent在生成行程时已经获取的。如果在前端计算,就需要重复这些逻辑,而且可能不准确。

在PlannerAgent的提示词中,我们明确要求LLM生成预算信息:

PLANNER_AGENT_PROMPT = """
你是行程规划专家。

**输出格式:**
严格按照以下JSON格式返回:
{
  ...
  "budget": {
    "total_attractions": 180,
    "total_hotels": 1200,
    "total_meals": 480,
    "total_transportation": 200,
    "total": 2060
  }
}

**规划要求:**
...
7. 包含预算信息,根据景点门票、酒店价格、餐饮标准和交通方式估算
"""

LLM会根据行程中的景点、酒店、餐饮安排,估算每一项的费用。比如,如果行程中包含故宫(门票60元)、天坛(门票15元)、颐和园(门票30元),那么景点门票总费用就是105元。如果是3天2晚的行程,酒店是经济型(每晚300元),那么酒店总费用就是600元。

在前端,我们使用Ant Design Vue的Statistic组件来展示预算信息。这个组件专门用于展示统计数据,支持数字动画、前缀后缀、自定义样式等:

<a-card v-if="tripPlan.budget" title="💰 预算明细">
  <a-row :gutter="16">
    <a-col :span="6">
      <a-statistic title="景点门票" :value="tripPlan.budget.total_attractions" suffix="元" />
    </a-col>
    <a-col :span="6">
      <a-statistic title="酒店住宿" :value="tripPlan.budget.total_hotels" suffix="元" />
    </a-col>
    <a-col :span="6">
      <a-statistic title="餐饮费用" :value="tripPlan.budget.total_meals" suffix="元" />
    </a-col>
    <a-col :span="6">
      <a-statistic title="交通费用" :value="tripPlan.budget.total_transportation" suffix="元" />
    </a-col>
  </a-row>
  <a-divider />
  <a-row>
    <a-col :span="24" style="text-align: center;">
      <a-statistic
        title="预估总费用"
        :value="tripPlan.budget.total"
        suffix="元"
        :value-style="{ color: '#cf1322',fontSize: '32px',fontWeight: 'bold' }"
      />
    </a-col>
  </a-row>
</a-card>

这段代码使用了栅格布局(a-rowa-col),将四项费用并排显示。每项费用使用一个a-statistic组件,显示标题和数值。最后用一个分隔线(a-divider)隔开,下面显示总费用,使用红色大字体突出显示。

注意v-if="tripPlan.budget"这个条件渲染。因为预算信息是可选的(在Pydantic模型中定义为Optional[Budget]),如果LLM没有生成预算信息,这个卡片就不会显示。这体现了前端对数据的容错处理。

13.6.2 加载进度条

生成旅行计划是一个耗时的操作。后端需要依次调用AttractionSearchAgent、WeatherQueryAgent、HotelAgent和PlannerAgent,每个Agent都要调用LLM和外部API。整个过程可能需要10-30秒。如果用户点击"开始规划"按钮后,页面没有任何反馈,用户会以为系统卡住了,可能会刷新页面或重复点击。

为了提升用户体验,我们添加了加载进度条和状态提示。现在只是模拟进度,可以让用户知道系统正在工作。

const loading = ref(false)
const loadingProgress = ref(0)
const loadingStatus = ref('')

const handleSubmit = async () => {
  loading.value = true
  loadingProgress.value = 0

  // 模拟进度更新
  const progressInterval = setInterval(() => {
    if (loadingProgress.value < 90) {
      loadingProgress.value += 10
      if (loadingProgress.value <= 30) loadingStatus.value = '🔍 正在搜索景点...'
      else if (loadingProgress.value <= 50) loadingStatus.value = '🌤️ 正在查询天气...'
      else if (loadingProgress.value <= 70) loadingStatus.value = '🏨 正在推荐酒店...'
      else loadingStatus.value = '📋 正在生成行程计划...'
    }
  }, 500)

  try {
    const response = await generateTripPlan(formData.value)
    clearInterval(progressInterval)
    loadingProgress.value = 100
    loadingStatus.value = '✅ 完成!'
    router.push({ name: 'result', state: { tripPlan: response } })
  } catch (error) {
    clearInterval(progressInterval)
    message.error('生成计划失败')
  } finally {
    loading.value = false
  }
}

13.6.3 行程编辑功能

AI生成的旅行计划虽然很智能,但可能不完全符合用户的个人需求。比如,用户可能不喜欢某个景点,想删除它;或者想调整景点的游览顺序。我们提供了行程编辑功能,让用户可以自定义行程。

编辑功能的核心是状态管理。我们需要维护两个状态:当前的行程计划和原始的行程计划。当用户进入编辑模式时,我们保存原始计划的副本。如果用户取消编辑,就恢复原始计划。如果用户保存修改,就更新当前计划:

const editMode = ref(false)
const originalPlan = ref<TripPlan | null>(null)

// 进入编辑模式
const toggleEditMode = () => {
  editMode.value = true
  originalPlan.value = JSON.parse(JSON.stringify(tripPlan.value))
}

注意这里使用了JSON.parse(JSON.stringify(...))来深拷贝对象。为什么不直接赋值?因为JavaScript中对象是引用类型,如果直接赋值,originalPlantripPlan会指向同一个对象,修改一个会影响另一个。深拷贝可以创建一个完全独立的副本。

移动景点的逻辑是交换数组中两个元素的位置:

// 移动景点
const moveAttraction = (dayIndex: number,attractionIndex: number,direction: 'up' | 'down') => {
  const attractions = tripPlan.value.days[dayIndex].attractions
  const newIndex = direction === 'up' ? attractionIndex - 1 : attractionIndex + 1
  
  if (newIndex >= 0 && newIndex < attractions.length) {
    [attractions[attractionIndex],attractions[newIndex]] = 
    [attractions[newIndex],attractions[attractionIndex]]
  }
}

这里使用了ES6的解构赋值语法来交换两个元素。[a,b] = [b,a]是一个很优雅的交换方式,不需要临时变量。

删除景点使用数组的splice方法:

// 删除景点
const deleteAttraction = (dayIndex: number,attractionIndex: number) => {
  tripPlan.value.days[dayIndex].attractions.splice(attractionIndex,1)
}

保存修改时,我们需要重新初始化地图,因为景点的位置可能发生了变化:

// 保存修改
const saveChanges = () => {
  editMode.value = false
  message.success('修改已保存')
  initMap()  // 重新初始化地图
}

// 取消编辑
const cancelEdit = () => {
  if (originalPlan.value) {
    tripPlan.value = originalPlan.value
  }
  editMode.value = false
}

在模板中,我们根据editMode的值显示不同的UI。编辑模式下,每个景点旁边会显示上移、下移、删除按钮:

<div v-if="editMode" class="edit-buttons">
  <a-button size="small" @click="moveAttraction(dayIndex,index,'up')">上移</a-button>
  <a-button size="small" @click="moveAttraction(dayIndex,index,'down')">下移</a-button>
  <a-button size="small" danger @click="deleteAttraction(dayIndex,index)">删除</a-button>
</div>

13.6.4 导出功能

用户生成了满意的旅行计划后,可能想保存下来或分享给朋友。我们提供了两种导出方式:导出为图片和导出为PDF。

导出功能的核心是html2canvas库。这个库可以把DOM元素转换成Canvas,然后我们可以把Canvas导出为图片。但这里有一个技术难点:地图是用Canvas渲染的,而html2canvas在处理嵌套Canvas时存在兼容性问题。

我们尝试了多种解决方案,包括将地图Canvas转换成图片后再导出,但由于高德地图的Canvas渲染机制和跨域限制,这个方案并没有完全解决问题。在实际项目中,可能需要考虑以下替代方案:

  1. 使用高德地图的静态地图API:调用maps_staticmap工具生成静态地图图片,替代动态地图
  2. 分开导出:地图和行程内容分开导出,最后在后端合并
  3. 使用截图服务:使用Puppeteer等无头浏览器在服务端截图
  4. 简化导出内容:导出时隐藏地图,只导出文字内容

目前的实现中,我们采用了简化方案,在导出时暂时隐藏地图部分,只导出行程的文字内容和景点信息。虽然这不是最理想的方案,但可以保证导出功能的可用性。

导出为图片的逻辑很简单:

import html2canvas from 'html2canvas'

const exportAsImage = async () => {
  const element = document.getElementById('trip-plan-content')
  if (!element) return
  
  const canvas = await html2canvas(element,{
    backgroundColor: '#ffffff',
    scale: 2,
    useCORS: true
  })
  
  const link = document.createElement('a')
  link.download = `${tripPlan.value.city}旅行计划.png`
  link.href = canvas.toDataURL('image/png')
  link.click()
  message.success('导出成功!')
}

scale: 2表示使用2倍分辨率,这样导出的图片更清晰。useCORS: true允许跨域加载图片,这对于景点图片(来自Unsplash)很重要。

导出为PDF需要额外的步骤:先转换成Canvas,再转换成图片,最后添加到PDF中:

import jsPDF from 'jspdf'

const exportAsPDF = async () => {
  // 先截取地图
  await captureMapImage()
  
  const element = document.getElementById('trip-plan-content')
  if (!element) return
  
  const canvas = await html2canvas(element,{
    backgroundColor: '#ffffff',
    scale: 2,
    useCORS: true,
    allowTaint: true
  })
  
  // 恢复地图
  restoreMap()
  
  const pdf = new jsPDF('p','mm','a4')
  const imgData = canvas.toDataURL('image/png')
  const imgWidth = 210  // A4宽度
  const imgHeight = (canvas.height * imgWidth) / canvas.width
  
  pdf.addImage(imgData,'PNG',0,0,imgWidth,imgHeight)
  pdf.save(`${tripPlan.value.city}旅行计划.pdf`)
  message.success('导出成功!')
}

这里需要计算图片的高度,保持宽高比。A4纸的宽度是210mm,我们根据Canvas的宽高比计算出对应的高度。

13.6.5 侧边导航与锚点跳转

Result页面的内容很多,包括行程概览、预算明细、地图、每日行程、天气信息等。如果用户想快速跳转到某个部分,需要滚动很长的距离。我们提供了侧边导航和锚点跳转功能,让用户可以快速定位。

侧边导航使用Ant Design Vue的Menu组件:

<a-menu
  v-model:selectedKeys="[activeSection]"
  mode="inline"
  @click="scrollToSection"
>
  <a-menu-item key="overview">📋 行程概览</a-menu-item>
  <a-menu-item key="budget">💰 预算明细</a-menu-item>
  <a-menu-item key="map">🗺️ 地图</a-menu-item>
  <a-menu-item key="days">📅 每日行程</a-menu-item>
  <a-menu-item key="weather">🌤️ 天气</a-menu-item>
</a-menu>

点击菜单项时,调用scrollToSection函数:

const activeSection = ref('overview')

// 滚动到指定区域
const scrollToSection = ({ key }: { key: string }) => {
  activeSection.value = key
  const element = document.getElementById(key)
  if (element) {
    element.scrollIntoView({ behavior: 'smooth',block: 'start' })
  }
}

scrollIntoView是浏览器原生的API,可以让元素滚动到可视区域。behavior: 'smooth'表示平滑滚动,而不是瞬间跳转。block: 'start'表示元素的顶部对齐到可视区域的顶部。

在页面的各个部分,我们需要添加对应的id:

<div id="overview">
  <!-- 行程概览内容 -->
</div>

<div id="budget">
  <!-- 预算明细内容 -->
</div>

<div id="map">
  <!-- 地图内容 -->
</div>

这样,当用户点击侧边导航的某个菜单项时,页面会平滑滚动到对应的部分。

通过这些功能的实现,我们的智能旅行助手不仅能够生成旅行计划,还提供了丰富的交互功能:预算计算让用户了解费用,加载进度条让等待不再焦虑,行程编辑让计划更符合个人需求,导出功能让计划可以分享和保存,侧边导航让长页面易于浏览。这些功能的组合,构成了一个完整、易用、实用的Web应用。

13.7 结语

恭喜你完成了第十三章的学习!

通过本章,你不仅学会了如何构建一个完整的智能旅行助手应用,更重要的是掌握了:

  1. 系统设计思维: 如何将复杂问题分解为多个简单任务
  2. 工程实践能力: 如何将理论知识转化为可运行的代码
  3. 全栈开发能力: 如何整合前后端技术栈
  4. AI应用开发: 如何利用LLM构建实用的应用

这个项目是一个起点,而不是终点。你可以基于这个项目:

  • 添加更多功能
  • 优化用户体验
  • 扩展到其他领域(如智能购物助手、智能学习助手等)
  • 部署到生产环境,服务真实用户

最好的学习方式是实践。不要只是阅读代码,而是要动手修改、扩展、优化。每一次实践都会让你对多Agent系统有更深的理解。

祝你在AI应用开发的道路上越走越远!