主题
LangChain 教程 36|项目实战:AI 旅游规划师
📖 本篇导读:这是 LangChain 系列教程的第 36 篇。本篇将构建一个智能旅游规划助手,支持智能对话、多工具并行、行程规划、行程编辑和保存。读完预计需要 20 分钟。
项目概述
简单来说
构建一个智能旅游规划助手,用户只需说"我想去杭州玩3天,预算5000",系统就能自动:
- 查询目的地天气
- 搜索机票/火车票
- 推荐酒店住宿
- 规划景点和餐厅
- 生成完整的多日行程表
用户可以在线修改行程,确认后保存。
核心功能
| 功能 | 描述 |
|---|---|
| 智能对话 | 自然语言理解用户出行需求 |
| 多工具并行 | 同时查询天气、交通、住宿、景点 |
| 行程规划 | 自动编排多日行程,合理安排时间 |
| 行程编辑 | 可视化编辑行程,拖拽调整顺序 |
| 行程保存 | 保存历史行程,支持分享 |

技术亮点
┌─────────────────────────────────────────────────────────────┐
│ AI 旅游规划师技术架构 │
├─────────────────────────────────────────────────────────────┤
│ 前端:React 18 + TypeScript + Ant Design + Zustand │
│ 后端:Express + Prisma + MySQL + Redis │
│ AI:LangChain 1.x + 多工具并行 + 结构化输出 │
│ 外部API:高德地图 + 天气API + 12306 │
└─────────────────────────────────────────────────────────────┘一、系统架构
1.1 整体架构图

┌─────────────────────────────────────────────────────────────────────┐
│ 前端层 │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
│ │ 对话页面 │ │ 行程页面 │ │ 编辑页面 │ │ 历史页面 │ │
│ └────┬─────┘ └────┬─────┘ └────┬─────┘ └────┬─────┘ │
│ └──────────────┼──────────────┼──────────────┘ │
│ ▼ │
│ ┌──────────────┐ │
│ │ Zustand Store │ ← 状态管理 │
│ └──────┬───────┘ │
└─────────────────────┼───────────────────────────────────────────────┘
│ HTTP/SSE
┌─────────────────────┼───────────────────────────────────────────────┐
│ ▼ 后端层 │
│ ┌──────────────────────────────────────────────────────────────┐ │
│ │ Express API Server │ │
│ │ ┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────┐ │ │
│ │ │ /chat │ │ /trips │ │ /users │ │ /stream │ │ │
│ │ └────┬────┘ └────┬────┘ └────┬────┘ └────┬────┘ │ │
│ └───────┼────────────┼────────────┼────────────┼───────────────┘ │
│ │ │ │ │ │
│ ┌───────┴────────────┴────────────┴────────────┴───────────────┐ │
│ │ Service Layer │ │
│ │ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │ │
│ │ │ ChatService │ │ TripService │ │ UserService │ │ │
│ │ └──────┬──────┘ └──────┬──────┘ └──────┬──────┘ │ │
│ └─────────┼────────────────┼────────────────┼──────────────────┘ │
│ │ │ │ │
│ ┌─────────┴────────────────┴────────────────┴──────────────────┐ │
│ │ AI Agent Layer │ │
│ │ ┌─────────────────────────────────────────────────────────┐ │ │
│ │ │ TravelPlannerAgent (LangChain) │ │ │
│ │ │ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ │ │ │
│ │ │ │ Weather │ │Transport │ │ Hotel │ │ POI │ │ │ │
│ │ │ │ Tool │ │ Tool │ │ Tool │ │ Tool │ │ │ │
│ │ │ └────┬─────┘ └────┬─────┘ └────┬─────┘ └────┬─────┘ │ │ │
│ │ └───────┼────────────┼────────────┼────────────┼─────────┘ │ │
│ └──────────┼────────────┼────────────┼────────────┼───────────┘ │
└─────────────┼────────────┼────────────┼────────────┼──────────────┘
│ │ │ │
┌─────────────┼────────────┼────────────┼────────────┼──────────────┐
│ ▼ ▼ ▼ ▼ 外部API层 │
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ ┌──────────┐ │
│ │ 高德天气 │ │ 12306 │ │ 高德酒店 │ │ 高德POI │ │
│ └──────────────┘ └──────────────┘ └──────────────┘ └──────────┘ │
└─────────────────────────────────────────────────────────────────────┘
│
┌─────────────┼───────────────────────────────────────────────────────┐
│ ▼ 数据层 │
│ ┌──────────────┐ ┌──────────────┐ │
│ │ MySQL │ │ Redis │ │
│ │ (Prisma) │ │ (缓存) │ │
│ └──────────────┘ └──────────────┘ │
└─────────────────────────────────────────────────────────────────────┘1.2 核心流程
用户输入 "我想去杭州玩3天,预算5000"
│
▼
┌─────────────────────────────────────────┐
│ Step 1: 需求解析 │
│ 提取:目的地=杭州, 天数=3, 预算=5000 │
└─────────────────┬───────────────────────┘
│
▼
┌─────────────────────────────────────────┐
│ Step 2: 并行信息收集 │
│ ┌─────────┐ ┌─────────┐ ┌─────────┐ │
│ │查天气 │ │查交通 │ │查酒店 │ │
│ │(异步) │ │(异步) │ │(异步) │ │
│ └─────────┘ └─────────┘ └─────────┘ │
└─────────────────┬───────────────────────┘
│
▼
┌─────────────────────────────────────────┐
│ Step 3: 景点推荐 │
│ 根据天数和预算,推荐合适的景点和餐厅 │
└─────────────────┬───────────────────────┘
│
▼
┌─────────────────────────────────────────┐
│ Step 4: 行程编排 │
│ 生成结构化 JSON 格式的多日行程表 │
└─────────────────┬───────────────────────┘
│
▼
┌─────────────────────────────────────────┐
│ Step 5: 用户确认 │
│ 展示行程 → 用户编辑 → 确认保存 │
└─────────────────────────────────────────┘二、数据库设计
2.1 ER 图
┌──────────────────┐ ┌──────────────────┐
│ users │ │ conversations │
├──────────────────┤ ├──────────────────┤
│ id (PK) │───┐ │ id (PK) │
│ email │ │ │ userId (FK) │──┐
│ password │ └──→│ title │ │
│ nickname │ │ status │ │
│ avatar │ │ createdAt │ │
│ createdAt │ │ updatedAt │ │
│ updatedAt │ └────────┬─────────┘ │
└──────────────────┘ │ │
│ │
┌──────────────────┐ │ │
│ messages │←───────────────┘ │
├──────────────────┤ │
│ id (PK) │ │
│ conversationId │ │
│ role │ (user/assistant/tool) │
│ content │ │
│ toolCalls │ (JSON) │
│ toolCallId │ │
│ createdAt │ │
└──────────────────┘ │
│
┌──────────────────┐ ┌──────────────────┐ │
│ trips │ │ trip_days │ │
├──────────────────┤ ├──────────────────┤ │
│ id (PK) │───┐ │ id (PK) │ │
│ userId (FK) │←──┼───│ tripId (FK) │ │
│ conversationId │←──┼───│ dayNumber │ │
│ title │ │ │ date │ │
│ destination │ │ │ createdAt │ │
│ startDate │ │ └────────┬─────────┘ │
│ endDate │ │ │ │
│ budget │ │ ▼ │
│ status │ │ ┌──────────────────┐ │
│ weatherInfo │ │ │ trip_activities │ │
│ transportInfo │ │ ├──────────────────┤ │
│ hotelInfo │ │ │ id (PK) │ │
│ createdAt │ │ │ tripDayId (FK) │ │
│ updatedAt │ │ │ type │ │
└──────────────────┘ │ │ name │ │
│ │ location │ │
│ │ startTime │ │
│ │ endTime │ │
│ │ duration │ │
│ │ cost │ │
│ │ notes │ │
│ │ order │ │
│ │ poiId │ │
│ │ poiData (JSON) │ │
│ └──────────────────┘ │
│ │
└────────────────────────┘2.2 Prisma Schema
prisma
// prisma/schema.prisma
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "mysql"
url = env("DATABASE_URL")
}
model User {
id String @id @default(uuid())
email String @unique
password String
nickname String?
avatar String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
conversations Conversation[]
trips Trip[]
@@map("users")
}
model Conversation {
id String @id @default(uuid())
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
title String @default("新对话")
status String @default("active") // active, archived
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
messages Message[]
trips Trip[]
@@index([userId])
@@map("conversations")
}
model Message {
id String @id @default(uuid())
conversationId String
conversation Conversation @relation(fields: [conversationId], references: [id], onDelete: Cascade)
role String // user, assistant, tool
content String @db.Text
toolCalls Json? // 工具调用信息
toolCallId String? // 工具调用ID(tool消息时使用)
createdAt DateTime @default(now())
@@index([conversationId])
@@map("messages")
}
model Trip {
id String @id @default(uuid())
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
conversationId String?
conversation Conversation? @relation(fields: [conversationId], references: [id], onDelete: SetNull)
title String
destination String
startDate DateTime
endDate DateTime
budget Decimal @db.Decimal(10, 2)
status String @default("draft") // draft, confirmed, completed
weatherInfo Json? // 天气信息缓存
transportInfo Json? // 交通信息缓存
hotelInfo Json? // 酒店信息缓存
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
days TripDay[]
@@index([userId])
@@index([conversationId])
@@map("trips")
}
model TripDay {
id String @id @default(uuid())
tripId String
trip Trip @relation(fields: [tripId], references: [id], onDelete: Cascade)
dayNumber Int
date DateTime
createdAt DateTime @default(now())
activities TripActivity[]
@@unique([tripId, dayNumber])
@@index([tripId])
@@map("trip_days")
}
model TripActivity {
id String @id @default(uuid())
tripDayId String
tripDay TripDay @relation(fields: [tripDayId], references: [id], onDelete: Cascade)
type String // attraction, restaurant, transport, hotel, free
name String
location String?
startTime String // HH:mm 格式
endTime String // HH:mm 格式
duration Int // 分钟
cost Decimal @db.Decimal(10, 2)
notes String? @db.Text
order Int // 排序顺序
poiId String? // 高德POI ID
poiData Json? // POI详细数据缓存
@@index([tripDayId])
@@map("trip_activities")
}三、后端 API 设计
3.1 API 概览
| 模块 | 方法 | 路径 | 描述 |
|---|---|---|---|
| 认证 | POST | /api/auth/register | 用户注册 |
| POST | /api/auth/login | 用户登录 | |
| POST | /api/auth/refresh | 刷新Token | |
| POST | /api/auth/logout | 退出登录 | |
| 对话 | GET | /api/conversations | 获取对话列表 |
| POST | /api/conversations | 创建新对话 | |
| GET | /api/conversations/:id | 获取对话详情 | |
| DELETE | /api/conversations/:id | 删除对话 | |
| 聊天 | POST | /api/chat | 发送消息(非流式) |
| GET | /api/chat/stream | 流式聊天(SSE) | |
| 行程 | GET | /api/trips | 获取行程列表 |
| GET | /api/trips/:id | 获取行程详情 | |
| PUT | /api/trips/:id | 更新行程 | |
| DELETE | /api/trips/:id | 删除行程 | |
| POST | /api/trips/:id/confirm | 确认行程 | |
| PUT | /api/trips/:id/activities | 批量更新活动 |
3.2 API 详细设计
3.2.1 认证模块
typescript
/**
* @openapi
* /api/auth/register:
* post:
* tags: [Auth]
* summary: 用户注册
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* required: [email, password]
* properties:
* email:
* type: string
* format: email
* password:
* type: string
* minLength: 6
* nickname:
* type: string
* responses:
* 201:
* description: 注册成功
* content:
* application/json:
* schema:
* type: object
* properties:
* accessToken:
* type: string
* refreshToken:
* type: string
* user:
* $ref: '#/components/schemas/User'
*/
// 请求体 Schema
const RegisterSchema = z.object({
email: z.string().email("邮箱格式不正确"),
password: z.string().min(6, "密码至少6位"),
nickname: z.string().optional(),
});
// 响应示例
{
"accessToken": "eyJhbGciOiJIUzI1NiIs...",
"refreshToken": "eyJhbGciOiJIUzI1NiIs...",
"user": {
"id": "uuid",
"email": "user@example.com",
"nickname": "旅行者",
"avatar": null,
"createdAt": "2024-01-01T00:00:00.000Z"
}
}3.2.2 聊天模块(核心)
typescript
/**
* @openapi
* /api/chat/stream:
* get:
* tags: [Chat]
* summary: 流式聊天(SSE)
* parameters:
* - name: conversationId
* in: query
* required: true
* schema:
* type: string
* - name: message
* in: query
* required: true
* schema:
* type: string
* responses:
* 200:
* description: SSE 流
* content:
* text/event-stream:
* schema:
* type: string
*/
// SSE 事件类型
type SSEEventType =
| "message_start" // 消息开始
| "content_delta" // 内容增量
| "tool_call_start" // 工具调用开始
| "tool_call_delta" // 工具调用参数增量
| "tool_call_end" // 工具调用结束
| "tool_result" // 工具执行结果
| "message_end" // 消息结束
| "trip_generated" // 行程生成完成
| "error"; // 错误
// SSE 消息格式
interface SSEMessage {
event: SSEEventType;
data: {
id?: string;
content?: string;
toolCall?: {
id: string;
name: string;
arguments: string;
};
toolResult?: {
toolCallId: string;
result: unknown;
};
trip?: Trip;
error?: string;
};
}
// SSE 响应示例
event: message_start
data: {"id": "msg_1"}
event: content_delta
data: {"content": "好的,"}
event: content_delta
data: {"content": "我来帮您规划"}
event: tool_call_start
data: {"toolCall": {"id": "call_1", "name": "get_weather", "arguments": ""}}
event: tool_call_delta
data: {"toolCall": {"arguments": "{\"city\":"}}
event: tool_call_delta
data: {"toolCall": {"arguments": "\"杭州\"}"}}
event: tool_call_end
data: {"toolCall": {"id": "call_1"}}
event: tool_result
data: {"toolResult": {"toolCallId": "call_1", "result": {"temp": 25, "weather": "晴"}}}
event: trip_generated
data: {"trip": {"id": "trip_1", "title": "杭州3日游", ...}}
event: message_end
data: {"id": "msg_1"}3.2.3 行程模块
typescript
/**
* @openapi
* /api/trips/{id}:
* put:
* tags: [Trips]
* summary: 更新行程
* parameters:
* - name: id
* in: path
* required: true
* schema:
* type: string
* requestBody:
* required: true
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/UpdateTripInput'
* responses:
* 200:
* description: 更新成功
*/
// 更新行程 Schema
const UpdateTripSchema = z.object({
title: z.string().optional(),
startDate: z.string().datetime().optional(),
endDate: z.string().datetime().optional(),
budget: z.number().positive().optional(),
days: z.array(z.object({
id: z.string().optional(),
dayNumber: z.number().int().positive(),
date: z.string().datetime(),
activities: z.array(z.object({
id: z.string().optional(),
type: z.enum(["attraction", "restaurant", "transport", "hotel", "free"]),
name: z.string(),
location: z.string().optional(),
startTime: z.string().regex(/^\d{2}:\d{2}$/),
endTime: z.string().regex(/^\d{2}:\d{2}$/),
duration: z.number().int().positive(),
cost: z.number().nonnegative(),
notes: z.string().optional(),
order: z.number().int(),
poiId: z.string().optional(),
})),
})).optional(),
});
// 行程响应结构
interface TripResponse {
id: string;
title: string;
destination: string;
startDate: string;
endDate: string;
budget: number;
status: "draft" | "confirmed" | "completed";
weatherInfo: WeatherInfo | null;
transportInfo: TransportInfo | null;
hotelInfo: HotelInfo | null;
days: TripDayResponse[];
createdAt: string;
updatedAt: string;
}
interface TripDayResponse {
id: string;
dayNumber: number;
date: string;
activities: TripActivityResponse[];
}
interface TripActivityResponse {
id: string;
type: string;
name: string;
location: string | null;
startTime: string;
endTime: string;
duration: number;
cost: number;
notes: string | null;
order: number;
poiId: string | null;
poiData: POIData | null;
}四、AI Agent 设计
4.1 整体架构模式
本项目采用 Router + Sub-Agent 混合模式,核心设计如下:

┌─────────────────────────────────────────────────────────────────────────────┐
│ 用户: "我想去杭州玩3天" │
└───────────────────────────────────┬─────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────────────────┐
│ 🎯 Coordinator Agent (协调器) │
│ ┌───────────────────────────────────────────────────────────────────────┐ │
│ │ 职责: │ │
│ │ 1. 理解用户意图,提取旅行参数(目的地、日期、天数、预算、偏好) │ │
│ │ 2. 根据意图路由到对应的 Sub-Agent │ │
│ │ 3. 整合各 Sub-Agent 的结果,返回给用户 │ │
│ │ 4. 处理用户的追问和行程修改请求 │ │
│ └───────────────────────────────────────────────────────────────────────┘ │
│ │
│ 路由决策: │
│ ┌──────────────────┐ ┌──────────────────┐ ┌──────────────────┐ │
│ │ 意图:规划新行程 │ │ 意图:查询信息 │ │ 意图:修改行程 │ │
│ │ ↓ │ │ ↓ │ │ ↓ │ │
│ │ → Planner Agent │ │ → Info Agent │ │ → Adjuster Agent │ │
│ └──────────────────┘ └──────────────────┘ └──────────────────┘ │
└─────────────────────────────────────────────────────────────────────────────┘
│
┌──────────────────────────┼──────────────────────────┐
│ │ │
▼ ▼ ▼
┌─────────────────────┐ ┌─────────────────────┐ ┌─────────────────────┐
│ 🔍 Info Collector │ │ 📋 Trip Planner │ │ ✏️ Trip Adjuster │
│ Sub-Agent │ │ Sub-Agent │ │ Sub-Agent │
│ │ │ │ │ │
│ 工具: │ │ 能力: │ │ 能力: │
│ • get_weather │ │ • 接收收集到的信息 │ │ • 解析用户修改意图 │
│ • search_train │ │ • 综合分析各类数据 │ │ • 重新规划时间安排 │
│ • search_hotel │ │ • 智能编排行程 │ │ • 处理活动增删改 │
│ • search_attraction │ │ • 优化时间安排 │ │ • 解决时间冲突 │
│ • search_restaurant │ │ • 生成结构化输出 │ │ • 更新费用估算 │
│ • plan_route │ │ │ │ │
│ • get_current_date │ │ 输出:TripPlanSchema│ │ 输出:更新后的行程 │
└─────────────────────┘ └─────────────────────┘ └─────────────────────┘
│ │ │
└──────────────────────────┼──────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────────────────┐
│ 📊 输出层 │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ 结构化行程 JSON → 保存到数据库 → 前端渲染展示 → 用户确认/修改 │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────────────────┘4.2 为什么选择这个架构?
4.2.1 架构对比

| 架构模式 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| 单 Agent + 多工具 | 简单直接 | 复杂任务时 Prompt 过长,推理负担大 | 简单查询场景 |
| 纯 Router 模式 | 清晰的职责分离 | 缺乏上下文共享,Agent 间协作困难 | 独立任务分发 |
| 纯 Sub-Agent 模式 | 灵活的任务分解 | 可能过度分解,调用链过长 | 复杂多步骤任务 |
| Router + Sub-Agent ✅ | 兼具路由灵活性和任务分解能力 | 实现复杂度稍高 | 旅游规划这类复杂场景 |
4.2.2 关键设计决策
Q: 行程生成应该是工具还是 Agent?
A: 应该是 Agent(Trip Planner Sub-Agent)
理由:
- 复杂推理:行程生成需要综合考虑天气、交通、景点距离、用餐时间、体力消耗等多个因素
- 创造性任务:需要 LLM 的创造力来安排有趣的行程主题和节奏
- 长输出:生成多日行程是长文本任务,作为工具返回容易被截断
- 迭代优化:可能需要多轮调整,Agent 比工具更适合这种场景
┌─────────────────────────────────────────────────────────────────────┐
│ 行程生成为什么不是工具? │
├─────────────────────────────────────────────────────────────────────┤
│ │
│ 工具(Tool)的定位: │
│ ┌─────────────────────────────────────────────────────────────┐ │
│ │ • 执行确定性操作(如调用 API 查询数据) │ │
│ │ • 输入明确,输出可预测 │ │
│ │ • 不需要复杂推理 │ │
│ │ • 例如:查天气、查火车票、搜索 POI │ │
│ └─────────────────────────────────────────────────────────────┘ │
│ │
│ Agent 的定位: │
│ ┌─────────────────────────────────────────────────────────────┐ │
│ │ • 需要复杂推理和决策 │ │
│ │ • 输入可能模糊,输出需要创造性 │ │
│ │ • 可能需要多轮思考和调整 │ │
│ │ • 例如:行程规划、行程优化、冲突解决 │ │
│ └─────────────────────────────────────────────────────────────┘ │
│ │
│ 行程生成的特点: │
│ ┌─────────────────────────────────────────────────────────────┐ │
│ │ ✓ 需要综合分析天气、交通、景点等多源信息 │ │
│ │ ✓ 需要创造性地安排每天的主题和节奏 │ │
│ │ ✓ 需要考虑景点地理位置进行路线优化 │ │
│ │ ✓ 需要平衡用户预算和体验 │ │
│ │ ✓ 输出是复杂的多日行程结构 │ │
│ │ → 结论:应该是 Agent,而非 Tool │ │
│ └─────────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────────┘4.3 Agent 详细设计
4.3.1 Coordinator Agent(协调器)
typescript
// src/agent/coordinator.agent.ts
const COORDINATOR_SYSTEM_PROMPT = `你是一个旅游规划协调员,负责理解用户需求并分配任务给专业的助手。
## 你的职责
1. 理解用户的旅行意图
2. 提取关键信息:目的地、日期、天数、预算、偏好、人数
3. 判断用户意图类型并路由到对应的处理流程
4. 整合结果并友好地回复用户
## 意图类型
- **plan_trip**:用户想规划新的旅行(如"我想去杭州玩")
- **query_info**:用户只是查询信息(如"杭州明天天气怎么样")
- **modify_trip**:用户想修改已有行程(如"把第二天的西湖换成灵隐寺")
- **general_chat**:闲聊或其他(如"你好")
## 输出格式
分析用户意图后,输出 JSON:
{
"intent": "plan_trip" | "query_info" | "modify_trip" | "general_chat",
"params": {
"destination": "杭州",
"days": 3,
"budget": 5000,
"startDate": "2024-02-01",
"travelers": 2,
"preferences": ["自然风景", "美食"]
},
"nextAction": "描述下一步操作"
}`;4.3.2 Info Collector Sub-Agent(信息收集器)
┌─────────────────────────────────────────────────────────────────────┐
│ Info Collector Sub-Agent │
│ 信息收集专家 │
├─────────────────────────────────────────────────────────────────────┤
│ │
│ 输入:{ destination: "杭州", days: 3, startDate: "2024-02-01" } │
│ │
│ ┌─────────────────────────────────────────────────────────────┐ │
│ │ 并行调用多个工具(多工具并行) │ │
│ │ │ │
│ │ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ │ │
│ │ │ Weather │ │Transport │ │ Hotel │ │Attraction│ │ │
│ │ │ Tool │ │ Tool │ │ Tool │ │ Tool │ │ │
│ │ └────┬─────┘ └────┬─────┘ └────┬─────┘ └────┬─────┘ │ │
│ │ │ │ │ │ │ │
│ │ ▼ ▼ ▼ ▼ │ │
│ │ 3天天气预报 高铁票信息 酒店列表 热门景点 │ │
│ │ │ │
│ │ ┌──────────┐ ┌──────────┐ │ │
│ │ │Restaurant│ │ Route │ │ │
│ │ │ Tool │ │ Tool │ │ │
│ │ └────┬─────┘ └────┬─────┘ │ │
│ │ │ │ │ │
│ │ ▼ ▼ │ │
│ │ 餐厅推荐 景点间路线 │ │
│ └─────────────────────────────────────────────────────────────┘ │
│ │
│ 输出:CollectedInfo 结构化数据 │
│ { │
│ weather: [...], │
│ transport: [...], │
│ hotels: [...], │
│ attractions: [...], │
│ restaurants: [...], │
│ routes: [...] │
│ } │
└─────────────────────────────────────────────────────────────────────┘4.3.3 Trip Planner Sub-Agent(行程规划器)
┌─────────────────────────────────────────────────────────────────────┐
│ Trip Planner Sub-Agent │
│ 行程规划专家 │
├─────────────────────────────────────────────────────────────────────┤
│ │
│ 输入:CollectedInfo + UserPreferences │
│ │
│ ┌─────────────────────────────────────────────────────────────┐ │
│ │ 智能规划逻辑 │ │
│ │ │ │
│ │ 1. 分析景点地理位置 → 聚类分组(相近景点放同一天) │ │
│ │ │ │
│ │ 2. 考虑天气因素: │ │
│ │ • 雨天 → 室内景点(博物馆、商场) │ │
│ │ • 晴天 → 户外景点(西湖、公园) │ │
│ │ │ │
│ │ 3. 时间安排原则: │ │
│ │ • 08:00 早餐 │ │
│ │ • 09:00-12:00 上午景点(2个) │ │
│ │ • 12:00-13:30 午餐 │ │
│ │ • 14:00-17:30 下午景点(2个) │ │
│ │ • 18:00-19:30 晚餐 │ │
│ │ • 20:00+ 夜间活动(可选) │ │
│ │ │ │
│ │ 4. 路线优化: │ │
│ │ • 使用路线规划 API 计算景点间交通时间 │ │
│ │ • 避免来回折腾 │ │
│ │ │ │
│ │ 5. 预算控制: │ │
│ │ • 计算总费用估算 │ │
│ │ • 超预算时调整酒店/餐厅级别 │ │
│ └─────────────────────────────────────────────────────────────┘ │
│ │
│ 输出:TripPlan 结构化行程 │
│ { │
│ title: "杭州3日休闲游", │
│ summary: "...", │
│ totalBudget: 4500, │
│ days: [ │
│ { │
│ dayNumber: 1, │
│ theme: "西湖经典游", │
│ activities: [...] │
│ }, │
│ ... │
│ ] │
│ } │
└─────────────────────────────────────────────────────────────────────┘4.3.4 Trip Adjuster Sub-Agent(行程调整器)
┌─────────────────────────────────────────────────────────────────────┐
│ Trip Adjuster Sub-Agent │
│ 行程调整专家 │
├─────────────────────────────────────────────────────────────────────┤
│ │
│ 输入:现有行程 + 用户修改请求 │
│ │
│ 处理场景: │
│ ┌─────────────────────────────────────────────────────────────┐ │
│ │ 场景1:替换活动 │ │
│ │ "把第二天的西湖换成灵隐寺" │ │
│ │ → 找到对应活动 → 搜索新景点信息 → 替换 → 调整时间 │ │
│ │ │ │
│ │ 场景2:增加活动 │ │
│ │ "第一天晚上想去看印象西湖" │ │
│ │ → 搜索活动信息 → 检查时间冲突 → 插入活动 → 调整后续时间 │ │
│ │ │ │
│ │ 场景3:删除活动 │ │
│ │ "第三天的河坊街我不想去了" │ │
│ │ → 找到对应活动 → 删除 → 重新分配时间或推荐替代 │ │
│ │ │ │
│ │ 场景4:调整顺序 │ │
│ │ "西溪湿地放到最后一天" │ │
│ │ → 找到对应活动 → 移动位置 → 重新优化路线 │ │
│ │ │ │
│ │ 场景5:时间冲突解决 │ │
│ │ 检测到冲突 → 自动调整 → 给出调整说明 │ │
│ └─────────────────────────────────────────────────────────────┘ │
│ │
│ 输出:更新后的 TripPlan + 修改说明 │
└─────────────────────────────────────────────────────────────────────┘4.4 完整工作流程

┌─────────────────────────────────────────────────────────────────────────────┐
│ 完整工作流程示例 │
│ 用户: "我想去杭州玩3天,预算5000" │
└─────────────────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────────────────┐
│ Step 1: Coordinator Agent 分析意图 │
│ ─────────────────────────────────────────────────────────────────────────── │
│ 输出: { │
│ intent: "plan_trip", │
│ params: { destination: "杭州", days: 3, budget: 5000 }, │
│ nextAction: "调用 Info Collector 收集信息" │
│ } │
└─────────────────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────────────────┐
│ Step 2: Info Collector Sub-Agent 并行收集信息 │
│ ─────────────────────────────────────────────────────────────────────────── │
│ │
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │
│ │ 天气查询 │ │ 火车票查询 │ │ 酒店搜索 │ │ 景点搜索 │ │
│ │ get_weather │ │search_train│ │search_hotel │ │search_attrac│ │
│ │ ↓ │ │ ↓ │ │ ↓ │ │ ↓ │ │
│ │ 杭州3天晴 │ │ G7501 ¥73 │ │ 如家 ¥299 │ │ 西湖 4.9分 │ │
│ └─────────────┘ └─────────────┘ └─────────────┘ └─────────────┘ │
│ │
│ ┌─────────────┐ ┌─────────────┐ │
│ │ 餐厅搜索 │ │ 路线规划 │ │
│ │search_resta │ │ plan_route │ │
│ │ ↓ │ │ ↓ │ │
│ │ 外婆家 4.5分│ │ 西湖→灵隐寺 │ │
│ │ │ │ 25分钟 │ │
│ └─────────────┘ └─────────────┘ │
│ │
│ 输出: CollectedInfo { weather, transport, hotels, attractions, ... } │
└─────────────────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────────────────┐
│ Step 3: Trip Planner Sub-Agent 生成行程 │
│ ─────────────────────────────────────────────────────────────────────────── │
│ │
│ 输入:CollectedInfo + { budget: 5000, days: 3 } │
│ │
│ 规划逻辑: │
│ • Day1: 西湖核心区(西湖 → 断桥 → 白堤 → 曲院风荷) │
│ • Day2: 历史人文区(灵隐寺 → 飞来峰 → 龙井茶村) │
│ • Day3: 现代杭州(西溪湿地 → 河坊街 → 钱塘江) │
│ │
│ 输出: TripPlan JSON │
└─────────────────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────────────────┐
│ Step 4: 返回用户 & 保存 │
│ ─────────────────────────────────────────────────────────────────────────── │
│ │
│ • 流式输出规划过程和结果给前端 │
│ • 保存行程到数据库(status: draft) │
│ • 前端渲染可视化行程卡片 │
│ • 用户可编辑/确认 │
└─────────────────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────────────────┐
│ Step 5: (可选) 用户修改 → Trip Adjuster │
│ ─────────────────────────────────────────────────────────────────────────── │
│ │
│ 用户: "灵隐寺换成宋城" │
│ │
│ → Coordinator 识别为 modify_trip 意图 │
│ → 调用 Trip Adjuster Sub-Agent │
│ → 搜索宋城信息 → 替换灵隐寺 → 重新优化路线 │
│ → 返回更新后的行程 │
└─────────────────────────────────────────────────────────────────────────────┘4.5 工具定义(共 8 个工具)

┌─────────────────────────────────────────────────────────────────────────────┐
│ 工具概览 │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ 序号 │ 工具名称 │ 功能描述 │ 外部 API │
│ ───────┼──────────────────┼────────────────────┼──────────────────────── │
│ 1 │ get_weather │ 查询天气预报 │ 高德天气 API │
│ 2 │ search_train │ 查询火车票 │ 12306 API │
│ 3 │ search_hotel │ 搜索酒店 │ 高德 POI API │
│ 4 │ search_attraction │ 搜索景点 ⭐ NEW │ 高德 POI API │
│ 5 │ search_restaurant │ 搜索餐厅 ⭐ NEW │ 高德 POI API │
│ 6 │ plan_route │ 路线规划 ⭐ NEW │ 高德路线规划 API │
│ 7 │ get_current_date │ 获取当前日期 │ 本地 │
│ 8 │ search_poi │ 通用 POI 搜索 │ 高德 POI API │
│ │
└─────────────────────────────────────────────────────────────────────────────┘4.5.1 天气查询工具
typescript
// src/agent/tools/weather.tool.ts
import { tool } from "@langchain/core/tools";
import { z } from "zod";
import { AmapService } from "@/services/amap.service";
export const getWeatherTool = tool(
async ({ city }) => {
const amapService = new AmapService();
const weather = await amapService.getWeather(city);
return JSON.stringify({
city,
current: {
temperature: weather.lives[0].temperature,
weather: weather.lives[0].weather,
humidity: weather.lives[0].humidity,
windDirection: weather.lives[0].winddirection,
windPower: weather.lives[0].windpower,
},
forecast: weather.forecasts[0]?.casts.map((cast: any) => ({
date: cast.date,
week: cast.week,
dayWeather: cast.dayweather,
nightWeather: cast.nightweather,
dayTemp: cast.daytemp,
nightTemp: cast.nighttemp,
})),
});
},
{
name: "get_weather",
description: "获取指定城市的天气信息,包括当前天气和未来几天的天气预报",
schema: z.object({
city: z.string().describe("城市名称,如:杭州、北京、上海"),
}),
}
);4.5.2 火车票查询工具
typescript
// src/agent/tools/transport.tool.ts
import { tool } from "@langchain/core/tools";
import { z } from "zod";
import { TrainService } from "@/services/train.service";
export const searchTrainTool = tool(
async ({ from, to, date }) => {
const trainService = new TrainService();
const tickets = await trainService.searchTickets(from, to, date);
// 只返回前10条,避免信息过多
const topTickets = tickets.slice(0, 10).map((ticket: any) => ({
trainNo: ticket.trainNo,
from: ticket.fromStation,
to: ticket.toStation,
departTime: ticket.departTime,
arriveTime: ticket.arriveTime,
duration: ticket.duration,
prices: {
secondClass: ticket.secondClassPrice,
firstClass: ticket.firstClassPrice,
businessClass: ticket.businessClassPrice,
},
seats: {
secondClass: ticket.secondClassSeats,
firstClass: ticket.firstClassSeats,
},
}));
return JSON.stringify({
from,
to,
date,
tickets: topTickets,
total: tickets.length,
});
},
{
name: "search_train",
description: "搜索火车票信息,包括车次、时间、价格和余票",
schema: z.object({
from: z.string().describe("出发城市"),
to: z.string().describe("到达城市"),
date: z.string().describe("出发日期,格式:YYYY-MM-DD"),
}),
}
);4.5.3 酒店搜索工具
typescript
// src/agent/tools/hotel.tool.ts
import { tool } from "@langchain/core/tools";
import { z } from "zod";
import { AmapService } from "@/services/amap.service";
export const searchHotelTool = tool(
async ({ city, keywords, priceRange }) => {
const amapService = new AmapService();
// 先获取城市的坐标
const geocode = await amapService.geocode(city);
const location = geocode.geocodes[0]?.location;
if (!location) {
return JSON.stringify({ error: "无法找到该城市" });
}
// 搜索周边酒店
const hotels = await amapService.searchPOI({
keywords: keywords || "酒店",
city,
types: "100100", // 酒店类型
radius: 10000,
});
// 过滤和排序
let filteredHotels = hotels.pois || [];
if (priceRange) {
// 根据价格范围过滤(这里简化处理,实际需要酒店价格API)
}
const result = filteredHotels.slice(0, 10).map((hotel: any) => ({
id: hotel.id,
name: hotel.name,
address: hotel.address,
location: hotel.location,
tel: hotel.tel,
rating: hotel.biz_ext?.rating,
distance: hotel.distance,
}));
return JSON.stringify({
city,
hotels: result,
total: filteredHotels.length,
});
},
{
name: "search_hotel",
description: "搜索酒店信息,支持按城市、关键词和价格范围筛选",
schema: z.object({
city: z.string().describe("城市名称"),
keywords: z.string().optional().describe("搜索关键词,如:五星级、经济型"),
priceRange: z.object({
min: z.number().describe("最低价格"),
max: z.number().describe("最高价格"),
}).optional().describe("价格范围"),
}),
}
);4.5.4 景点搜索工具(NEW)
typescript
// src/agent/tools/attraction.tool.ts
import { tool } from "@langchain/core/tools";
import { z } from "zod";
import { AmapService } from "@/services/amap.service";
export const searchAttractionTool = tool(
async ({ city, keywords, sortBy }) => {
const amapService = new AmapService();
const pois = await amapService.searchPOI({
keywords: keywords || "景点",
city,
types: "110000", // 风景名胜类型
page_size: 20,
});
let attractions = (pois.pois || []).map((poi: any) => ({
id: poi.id,
name: poi.name,
type: poi.type,
address: poi.address,
location: poi.location,
tel: poi.tel,
rating: parseFloat(poi.biz_ext?.rating || "0"),
cost: poi.biz_ext?.cost || "免费",
openTime: poi.biz_ext?.open_time || "全天",
photos: poi.photos?.slice(0, 3).map((p: any) => p.url),
tags: poi.biz_ext?.tag?.split(";") || [],
}));
// 排序
if (sortBy === "rating") {
attractions.sort((a: any, b: any) => b.rating - a.rating);
} else if (sortBy === "cost") {
attractions.sort((a: any, b: any) => {
const costA = a.cost === "免费" ? 0 : parseInt(a.cost) || 999;
const costB = b.cost === "免费" ? 0 : parseInt(b.cost) || 999;
return costA - costB;
});
}
return JSON.stringify({
city,
attractions: attractions.slice(0, 15),
total: pois.count,
tips: "建议每天安排2-3个景点,预留充足的交通和休息时间",
});
},
{
name: "search_attraction",
description: `搜索城市的旅游景点,包括景区、公园、古迹、博物馆等。
返回景点的评分、门票价格、开放时间等信息。
建议用于规划行程时获取景点列表。`,
schema: z.object({
city: z.string().describe("城市名称,如:杭州、北京"),
keywords: z.string().optional().describe("搜索关键词,如:西湖、古镇、博物馆"),
sortBy: z.enum(["rating", "cost", "default"])
.optional()
.default("rating")
.describe("排序方式:rating(评分)、cost(价格)、default(默认)"),
}),
}
);4.5.5 餐厅搜索工具(NEW)
typescript
// src/agent/tools/restaurant.tool.ts
import { tool } from "@langchain/core/tools";
import { z } from "zod";
import { AmapService } from "@/services/amap.service";
export const searchRestaurantTool = tool(
async ({ city, keywords, cuisine, priceLevel, nearLocation }) => {
const amapService = new AmapService();
// 构建搜索关键词
let searchKeywords = keywords || "餐厅";
if (cuisine) {
searchKeywords = `${cuisine} ${searchKeywords}`;
}
let searchParams: any = {
keywords: searchKeywords,
city,
types: "050000", // 餐饮服务类型
page_size: 20,
};
// 如果提供了位置,搜索周边
if (nearLocation) {
searchParams.location = nearLocation;
searchParams.radius = 3000; // 3公里范围
}
const pois = await amapService.searchPOI(searchParams);
let restaurants = (pois.pois || []).map((poi: any) => ({
id: poi.id,
name: poi.name,
type: poi.type,
cuisine: poi.biz_ext?.keytag || "综合",
address: poi.address,
location: poi.location,
tel: poi.tel,
rating: parseFloat(poi.biz_ext?.rating || "0"),
avgPrice: poi.biz_ext?.cost ? `人均¥${poi.biz_ext.cost}` : "暂无",
openTime: poi.biz_ext?.open_time || "营业中",
photos: poi.photos?.slice(0, 2).map((p: any) => p.url),
distance: poi.distance ? `${poi.distance}米` : null,
}));
// 根据价格等级过滤
if (priceLevel) {
const priceLevelMap: Record<string, [number, number]> = {
budget: [0, 50], // 经济实惠
moderate: [50, 150], // 中等
upscale: [150, 300], // 高档
luxury: [300, 9999], // 奢华
};
const [minPrice, maxPrice] = priceLevelMap[priceLevel] || [0, 9999];
restaurants = restaurants.filter((r: any) => {
const price = parseInt(r.avgPrice.replace(/[^0-9]/g, "")) || 0;
return price >= minPrice && price <= maxPrice;
});
}
// 按评分排序
restaurants.sort((a: any, b: any) => b.rating - a.rating);
return JSON.stringify({
city,
restaurants: restaurants.slice(0, 10),
total: restaurants.length,
tips: "建议提前查看营业时间,热门餐厅可能需要排队",
});
},
{
name: "search_restaurant",
description: `搜索城市的餐厅,支持按菜系、价格等级和位置筛选。
返回餐厅的评分、人均消费、营业时间等信息。
可以指定某个景点附近的餐厅,方便就近用餐。`,
schema: z.object({
city: z.string().describe("城市名称"),
keywords: z.string().optional().describe("搜索关键词"),
cuisine: z.string().optional().describe("菜系,如:杭帮菜、川菜、日料、火锅"),
priceLevel: z.enum(["budget", "moderate", "upscale", "luxury"])
.optional()
.describe("价格等级:budget(经济)、moderate(中等)、upscale(高档)、luxury(奢华)"),
nearLocation: z.string().optional()
.describe("附近位置的经纬度,格式:经度,纬度。用于搜索某景点附近的餐厅"),
}),
}
);4.5.6 路线规划工具(NEW)
typescript
// src/agent/tools/route.tool.ts
import { tool } from "@langchain/core/tools";
import { z } from "zod";
import { AmapService } from "@/services/amap.service";
export const planRouteTool = tool(
async ({ origin, destination, city, mode }) => {
const amapService = new AmapService();
// 如果输入的是地点名称而不是经纬度,先进行地理编码
let originLocation = origin;
let destLocation = destination;
if (!origin.includes(",")) {
const geocode = await amapService.geocode(`${city}${origin}`);
originLocation = geocode.geocodes[0]?.location || origin;
}
if (!destination.includes(",")) {
const geocode = await amapService.geocode(`${city}${destination}`);
destLocation = geocode.geocodes[0]?.location || destination;
}
// 调用路线规划 API
const route = await amapService.planRoute({
origin: originLocation,
destination: destLocation,
mode,
city,
});
// 解析不同交通方式的结果
let routeInfo: any = {
origin,
destination,
mode,
};
if (mode === "walking") {
const path = route.route?.paths?.[0];
routeInfo = {
...routeInfo,
distance: `${(parseInt(path?.distance || "0") / 1000).toFixed(1)}公里`,
duration: `${Math.round(parseInt(path?.duration || "0") / 60)}分钟`,
steps: path?.steps?.slice(0, 5).map((s: any) => s.instruction),
};
} else if (mode === "driving") {
const path = route.route?.paths?.[0];
routeInfo = {
...routeInfo,
distance: `${(parseInt(path?.distance || "0") / 1000).toFixed(1)}公里`,
duration: `${Math.round(parseInt(path?.duration || "0") / 60)}分钟`,
taxi_cost: path?.taxi_cost ? `约¥${path.taxi_cost}` : null,
tolls: path?.tolls ? `过路费¥${path.tolls}` : null,
traffic_lights: path?.traffic_lights,
};
} else if (mode === "transit") {
const transits = route.route?.transits || [];
const bestTransit = transits[0];
routeInfo = {
...routeInfo,
distance: `${(parseInt(bestTransit?.distance || "0") / 1000).toFixed(1)}公里`,
duration: `${Math.round(parseInt(bestTransit?.duration || "0") / 60)}分钟`,
cost: bestTransit?.cost ? `约¥${bestTransit.cost}` : null,
walking_distance: `步行${(parseInt(bestTransit?.walking_distance || "0") / 1000).toFixed(1)}公里`,
segments: bestTransit?.segments?.slice(0, 3).map((seg: any) => {
if (seg.bus?.buslines?.[0]) {
const bus = seg.bus.buslines[0];
return `乘坐 ${bus.name}(${bus.departure_stop?.name} → ${bus.arrival_stop?.name})`;
}
if (seg.walking) {
return `步行 ${seg.walking.distance}米`;
}
return null;
}).filter(Boolean),
alternatives: transits.slice(1, 3).map((t: any) => ({
duration: `${Math.round(parseInt(t.duration || "0") / 60)}分钟`,
cost: t.cost ? `¥${t.cost}` : null,
})),
};
}
return JSON.stringify(routeInfo);
},
{
name: "plan_route",
description: `规划两个地点之间的路线,支持步行、驾车、公共交通三种方式。
返回距离、预计时间、费用等信息。
用于计算景点之间的交通时间,优化行程安排。`,
schema: z.object({
origin: z.string().describe("起点名称或经纬度(格式:经度,纬度)"),
destination: z.string().describe("终点名称或经纬度"),
city: z.string().describe("城市名称,用于地理编码"),
mode: z.enum(["walking", "driving", "transit"])
.default("transit")
.describe("交通方式:walking(步行)、driving(驾车)、transit(公共交通)"),
}),
}
);4.5.7 通用 POI 搜索工具
typescript
// src/agent/tools/poi.tool.ts
import { tool } from "@langchain/core/tools";
import { z } from "zod";
import { AmapService } from "@/services/amap.service";
export const searchPOITool = tool(
async ({ city, type, keywords }) => {
const amapService = new AmapService();
// POI 类型映射
const typeMapping: Record<string, string> = {
attraction: "110000", // 风景名胜
restaurant: "050000", // 餐饮服务
shopping: "060000", // 购物服务
entertainment: "080000", // 娱乐
};
const pois = await amapService.searchPOI({
keywords: keywords || "",
city,
types: typeMapping[type] || "",
page_size: 20,
});
const result = (pois.pois || []).map((poi: any) => ({
id: poi.id,
name: poi.name,
type: poi.type,
typecode: poi.typecode,
address: poi.address,
location: poi.location,
tel: poi.tel,
rating: poi.biz_ext?.rating,
cost: poi.biz_ext?.cost,
openTime: poi.biz_ext?.open_time,
photos: poi.photos?.slice(0, 3).map((p: any) => p.url),
}));
return JSON.stringify({
city,
type,
pois: result,
total: pois.count,
});
},
{
name: "search_poi",
description: "搜索景点、餐厅、购物中心等兴趣点",
schema: z.object({
city: z.string().describe("城市名称"),
type: z.enum(["attraction", "restaurant", "shopping", "entertainment"])
.describe("POI类型:attraction(景点)、restaurant(餐厅)、shopping(购物)、entertainment(娱乐)"),
keywords: z.string().optional().describe("搜索关键词"),
}),
}
);4.5.8 获取当前日期工具
typescript
// src/agent/tools/date.tool.ts
import { tool } from "@langchain/core/tools";
import { z } from "zod";
export const getCurrentDateTool = tool(
async () => {
const now = new Date();
const year = now.getFullYear();
const month = String(now.getMonth() + 1).padStart(2, "0");
const day = String(now.getDate()).padStart(2, "0");
const weekDay = ["日", "一", "二", "三", "四", "五", "六"][now.getDay()];
return JSON.stringify({
date: `${year}-${month}-${day}`,
year,
month: now.getMonth() + 1,
day: now.getDate(),
weekDay: `星期${weekDay}`,
timestamp: now.getTime(),
});
},
{
name: "get_current_date",
description: "获取当前日期,用于计算出行日期",
schema: z.object({}),
}
);4.6 多 Agent 实现
4.6.1 Agent 系统整体结构
typescript
// src/agent/index.ts
import { StateGraph, Annotation, END, START } from "@langchain/langgraph";
import { BaseMessage, HumanMessage, AIMessage } from "@langchain/core/messages";
import { ChatOpenAI } from "@langchain/openai";
import { MemorySaver } from "@langchain/langgraph";
// 定义状态
const TravelPlannerState = Annotation.Root({
messages: Annotation<BaseMessage[]>({
reducer: (prev, next) => [...prev, ...next],
default: () => [],
}),
intent: Annotation<string>(),
params: Annotation<{
destination?: string;
days?: number;
budget?: number;
startDate?: string;
travelers?: number;
preferences?: string[];
}>(),
collectedInfo: Annotation<{
weather?: any;
transport?: any;
hotels?: any[];
attractions?: any[];
restaurants?: any[];
routes?: any[];
}>(),
tripPlan: Annotation<any>(),
currentTripId: Annotation<string>(),
});
export type TravelPlannerStateType = typeof TravelPlannerState.State;4.6.2 Coordinator Agent(协调器)
typescript
// src/agent/coordinator.agent.ts
import { ChatOpenAI } from "@langchain/openai";
import { z } from "zod";
const IntentSchema = z.object({
intent: z.enum(["plan_trip", "query_info", "modify_trip", "general_chat"]),
params: z.object({
destination: z.string().optional(),
days: z.number().optional(),
budget: z.number().optional(),
startDate: z.string().optional(),
travelers: z.number().optional(),
preferences: z.array(z.string()).optional(),
}),
reasoning: z.string(),
});
const COORDINATOR_PROMPT = `你是旅游规划协调员,负责理解用户意图并提取关键信息。
## 意图类型
- plan_trip: 用户想规划新行程("我想去杭州玩"、"帮我规划一个北京5日游")
- query_info: 用户只是查询信息("杭州天气"、"火车票多少钱")
- modify_trip: 用户想修改已有行程("把西湖换成灵隐寺"、"增加一天")
- general_chat: 闲聊("你好"、"谢谢")
## 参数提取
仔细分析用户消息,提取以下信息:
- destination: 目的地城市
- days: 旅行天数
- budget: 预算金额
- startDate: 出发日期
- travelers: 出行人数
- preferences: 偏好标签(自然风景、历史文化、美食、亲子等)
如果用户说"下周末"、"五一"等,设置 startDate 为相对描述,后续会计算具体日期。
如果信息不完整,设置为 null,后续可以追问。`;
export async function coordinatorAgent(state: TravelPlannerStateType) {
const model = new ChatOpenAI({
model: "gpt-4o",
temperature: 0,
}).withStructuredOutput(IntentSchema);
const lastMessage = state.messages[state.messages.length - 1];
const result = await model.invoke([
{ role: "system", content: COORDINATOR_PROMPT },
{ role: "user", content: lastMessage.content as string },
]);
return {
intent: result.intent,
params: result.params,
messages: [new AIMessage({
content: `[意图识别] ${result.intent}: ${result.reasoning}`,
})],
};
}
// 路由函数
export function routeByIntent(state: TravelPlannerStateType): string {
switch (state.intent) {
case "plan_trip":
return "info_collector";
case "query_info":
return "info_collector";
case "modify_trip":
return "trip_adjuster";
case "general_chat":
default:
return "responder";
}
}4.6.3 Info Collector Sub-Agent(信息收集器)
typescript
// src/agent/info-collector.agent.ts
import { createReactAgent } from "@langchain/langgraph/prebuilt";
import { ChatOpenAI } from "@langchain/openai";
import { SystemMessage } from "@langchain/core/messages";
import { getWeatherTool } from "./tools/weather.tool";
import { searchTrainTool } from "./tools/transport.tool";
import { searchHotelTool } from "./tools/hotel.tool";
import { searchAttractionTool } from "./tools/attraction.tool";
import { searchRestaurantTool } from "./tools/restaurant.tool";
import { planRouteTool } from "./tools/route.tool";
import { getCurrentDateTool } from "./tools/date.tool";
const INFO_COLLECTOR_PROMPT = `你是信息收集专家,负责收集旅行规划所需的各类信息。
## 你的工具
1. get_weather - 查询天气预报
2. search_train - 查询火车票
3. search_hotel - 搜索酒店
4. search_attraction - 搜索景点
5. search_restaurant - 搜索餐厅
6. plan_route - 规划路线
7. get_current_date - 获取当前日期
## 工作原则
1. **并行调用**:尽可能同时调用多个工具,提高效率
2. **全面收集**:天气、交通、住宿、景点、餐厅都要收集
3. **智能筛选**:根据预算筛选合适的酒店和餐厅
4. **路线预规划**:收集主要景点之间的路线信息
## 收集顺序
1. 先获取当前日期(如果用户说了相对日期)
2. 并行查询:天气、火车票、酒店
3. 再并行查询:景点、餐厅
4. 最后规划主要景点间的路线
收集完成后,整理成结构化的信息摘要返回。`;
export function createInfoCollectorAgent() {
const model = new ChatOpenAI({
model: "gpt-4o",
temperature: 0.3,
});
const tools = [
getWeatherTool,
searchTrainTool,
searchHotelTool,
searchAttractionTool,
searchRestaurantTool,
planRouteTool,
getCurrentDateTool,
];
return createReactAgent({
llm: model,
tools,
messageModifier: new SystemMessage(INFO_COLLECTOR_PROMPT),
});
}
export async function infoCollectorNode(state: TravelPlannerStateType) {
const agent = createInfoCollectorAgent();
// 构建收集信息的指令
const { destination, days, budget, startDate } = state.params;
const instruction = `
请为以下旅行收集信息:
- 目的地:${destination}
- 天数:${days}天
- 预算:${budget}元
- 出发日期:${startDate || "待定"}
请并行调用工具收集:
1. 天气预报(${days}天)
2. 火车票信息
3. 酒店推荐(符合预算)
4. 热门景点
5. 特色餐厅
6. 主要景点间的路线
`;
const result = await agent.invoke({
messages: [{ role: "user", content: instruction }],
});
// 解析收集到的信息
const collectedInfo = parseCollectedInfo(result.messages);
return {
collectedInfo,
messages: result.messages,
};
}
function parseCollectedInfo(messages: any[]) {
// 从 tool messages 中解析收集到的信息
const info: any = {
weather: null,
transport: null,
hotels: [],
attractions: [],
restaurants: [],
routes: [],
};
for (const msg of messages) {
if (msg._getType() === "tool") {
try {
const content = JSON.parse(msg.content);
if (content.city && content.current) {
info.weather = content;
} else if (content.tickets) {
info.transport = content;
} else if (content.hotels) {
info.hotels = content.hotels;
} else if (content.attractions) {
info.attractions = content.attractions;
} else if (content.restaurants) {
info.restaurants = content.restaurants;
} else if (content.origin && content.destination) {
info.routes.push(content);
}
} catch (e) {
// 忽略解析错误
}
}
}
return info;
}4.6.4 Trip Planner Sub-Agent(行程规划器)
typescript
// src/agent/trip-planner.agent.ts
import { ChatOpenAI } from "@langchain/openai";
import { z } from "zod";
// 结构化输出 Schema
const TripPlanSchema = z.object({
title: z.string().describe("行程标题,如:杭州3日休闲游"),
destination: z.string(),
startDate: z.string(),
endDate: z.string(),
totalBudget: z.number(),
summary: z.string().describe("行程简介,2-3句话"),
tips: z.array(z.string()).describe("出行小贴士,3-5条"),
days: z.array(z.object({
dayNumber: z.number(),
date: z.string(),
theme: z.string().describe("当天主题,如:西湖环线游"),
activities: z.array(z.object({
type: z.enum(["attraction", "restaurant", "transport", "hotel", "free"]),
name: z.string(),
location: z.string().optional(),
startTime: z.string().regex(/^\d{2}:\d{2}$/),
endTime: z.string().regex(/^\d{2}:\d{2}$/),
duration: z.number().describe("分钟"),
cost: z.number(),
notes: z.string().optional(),
poiId: z.string().optional(),
})),
})),
});
const TRIP_PLANNER_PROMPT = `你是专业的行程规划师,根据收集到的信息生成完美的旅行行程。
## 规划原则
### 1. 时间安排
- 08:00-09:00 早餐
- 09:00-12:00 上午活动(1-2个景点)
- 12:00-13:30 午餐
- 13:30-14:00 休息/移动
- 14:00-17:30 下午活动(1-2个景点)
- 18:00-19:30 晚餐
- 19:30-21:00 夜间活动(可选)
### 2. 景点安排
- 地理位置相近的景点放同一天
- 考虑景点开放时间
- 室外景点看天气安排
- 热门景点尽量安排在工作日
### 3. 餐饮安排
- 选择景点附近的餐厅
- 午餐可以简单,晚餐可以丰富
- 根据预算选择餐厅档次
### 4. 交通时间
- 景点之间预留交通时间
- 使用收集到的路线信息估算
- 高峰期适当增加时间
### 5. 预算控制
- 先算必要支出(交通、住宿)
- 再分配景点门票
- 剩余用于餐饮
## 输出要求
生成结构化的行程 JSON,每个活动都要有:
- 精确的开始和结束时间
- 预估费用
- 必要的备注`;
export async function tripPlannerNode(state: TravelPlannerStateType) {
const model = new ChatOpenAI({
model: "gpt-4o",
temperature: 0.7,
}).withStructuredOutput(TripPlanSchema);
const { destination, days, budget, startDate, preferences } = state.params;
const { weather, transport, hotels, attractions, restaurants, routes } = state.collectedInfo;
const prompt = `
## 旅行需求
- 目的地:${destination}
- 天数:${days}天
- 预算:${budget}元
- 出发日期:${startDate}
- 偏好:${preferences?.join("、") || "无特殊偏好"}
## 收集到的信息
### 天气预报
${JSON.stringify(weather, null, 2)}
### 交通信息
${JSON.stringify(transport, null, 2)}
### 酒店推荐
${JSON.stringify(hotels?.slice(0, 5), null, 2)}
### 热门景点
${JSON.stringify(attractions?.slice(0, 15), null, 2)}
### 特色餐厅
${JSON.stringify(restaurants?.slice(0, 10), null, 2)}
### 路线信息
${JSON.stringify(routes, null, 2)}
请根据以上信息,生成一个合理的 ${days} 天行程安排。
`;
const tripPlan = await model.invoke([
{ role: "system", content: TRIP_PLANNER_PROMPT },
{ role: "user", content: prompt },
]);
return {
tripPlan,
messages: [new AIMessage({
content: `行程规划完成!${tripPlan.title}\n\n${tripPlan.summary}`,
})],
};
}
export { TripPlanSchema };4.6.5 Trip Adjuster Sub-Agent(行程调整器)
typescript
// src/agent/trip-adjuster.agent.ts
import { createReactAgent } from "@langchain/langgraph/prebuilt";
import { ChatOpenAI } from "@langchain/openai";
import { SystemMessage } from "@langchain/core/messages";
import { searchAttractionTool } from "./tools/attraction.tool";
import { searchRestaurantTool } from "./tools/restaurant.tool";
import { planRouteTool } from "./tools/route.tool";
const TRIP_ADJUSTER_PROMPT = `你是行程调整专家,负责根据用户反馈修改已有行程。
## 你的能力
1. 理解用户的修改意图
2. 搜索新的景点/餐厅信息
3. 重新规划路线
4. 调整时间安排避免冲突
## 修改类型
1. **替换**:"把 X 换成 Y"
- 找到 X 在行程中的位置
- 搜索 Y 的信息
- 用 Y 替换 X
- 调整前后时间
2. **增加**:"增加 X" / "想去 X"
- 搜索 X 的信息
- 找到合适的时间段插入
- 调整后续活动时间
3. **删除**:"不想去 X" / "去掉 X"
- 找到 X 在行程中的位置
- 删除 X
- 重新分配释放的时间
4. **调整顺序**:"X 放到第 N 天"
- 移动 X 到目标位置
- 重新优化路线
## 注意事项
- 修改后检查时间冲突
- 更新当天的路线规划
- 重新计算费用估算
- 给出修改说明`;
export function createTripAdjusterAgent() {
const model = new ChatOpenAI({
model: "gpt-4o",
temperature: 0.3,
});
const tools = [
searchAttractionTool,
searchRestaurantTool,
planRouteTool,
];
return createReactAgent({
llm: model,
tools,
messageModifier: new SystemMessage(TRIP_ADJUSTER_PROMPT),
});
}
export async function tripAdjusterNode(state: TravelPlannerStateType) {
const agent = createTripAdjusterAgent();
const lastMessage = state.messages[state.messages.length - 1];
const currentTrip = state.tripPlan;
const instruction = `
当前行程:
${JSON.stringify(currentTrip, null, 2)}
用户请求:
${lastMessage.content}
请根据用户请求修改行程,并返回修改后的完整行程。
`;
const result = await agent.invoke({
messages: [{ role: "user", content: instruction }],
});
// 解析修改后的行程
const updatedTripPlan = parseUpdatedTrip(result.messages, currentTrip);
return {
tripPlan: updatedTripPlan,
messages: result.messages,
};
}
function parseUpdatedTrip(messages: any[], currentTrip: any) {
// 从 AI 回复中解析更新后的行程
// 实际实现中需要更复杂的解析逻辑
return currentTrip; // 简化示例
}4.6.6 组装完整的 Graph
typescript
// src/agent/travel-planner.graph.ts
import { StateGraph, END, START } from "@langchain/langgraph";
import { MemorySaver } from "@langchain/langgraph";
import { TravelPlannerState } from "./index";
import { coordinatorAgent, routeByIntent } from "./coordinator.agent";
import { infoCollectorNode } from "./info-collector.agent";
import { tripPlannerNode } from "./trip-planner.agent";
import { tripAdjusterNode } from "./trip-adjuster.agent";
// 回复节点(用于闲聊)
async function responderNode(state: typeof TravelPlannerState.State) {
return {
messages: [new AIMessage({
content: "你好!我是AI旅游规划师,告诉我你想去哪里玩,我来帮你规划完美的行程!🌴",
})],
};
}
// 最终响应节点
async function finalResponseNode(state: typeof TravelPlannerState.State) {
const tripPlan = state.tripPlan;
if (!tripPlan) {
return { messages: [] };
}
// 生成友好的行程描述
const response = formatTripResponse(tripPlan);
return {
messages: [new AIMessage({ content: response })],
};
}
function formatTripResponse(tripPlan: any): string {
let response = `## 🎉 ${tripPlan.title}\n\n`;
response += `${tripPlan.summary}\n\n`;
for (const day of tripPlan.days) {
response += `### Day ${day.dayNumber}: ${day.theme}\n`;
for (const activity of day.activities) {
const emoji = getActivityEmoji(activity.type);
response += `- ${emoji} **${activity.startTime}-${activity.endTime}** ${activity.name}`;
if (activity.cost > 0) {
response += ` (¥${activity.cost})`;
}
response += "\n";
}
response += "\n";
}
response += `### 💡 小贴士\n`;
for (const tip of tripPlan.tips) {
response += `- ${tip}\n`;
}
response += `\n**预估总费用:¥${tripPlan.totalBudget}**`;
return response;
}
function getActivityEmoji(type: string): string {
const emojiMap: Record<string, string> = {
attraction: "🏞️",
restaurant: "🍜",
transport: "🚄",
hotel: "🏨",
free: "☕",
};
return emojiMap[type] || "📍";
}
// 构建 Graph
export function createTravelPlannerGraph() {
const workflow = new StateGraph(TravelPlannerState)
// 添加节点
.addNode("coordinator", coordinatorAgent)
.addNode("info_collector", infoCollectorNode)
.addNode("trip_planner", tripPlannerNode)
.addNode("trip_adjuster", tripAdjusterNode)
.addNode("responder", responderNode)
.addNode("final_response", finalResponseNode)
// 添加边
.addEdge(START, "coordinator")
.addConditionalEdges("coordinator", routeByIntent)
.addEdge("info_collector", "trip_planner")
.addEdge("trip_planner", "final_response")
.addEdge("trip_adjuster", "final_response")
.addEdge("responder", END)
.addEdge("final_response", END);
// 添加 checkpointer 支持多轮对话
const checkpointer = new MemorySaver();
return workflow.compile({ checkpointer });
}
// 导出流式调用方法
export async function* streamTravelPlanner(
message: string,
threadId: string
) {
const graph = createTravelPlannerGraph();
const config = {
configurable: { thread_id: threadId },
streamMode: "messages" as const,
};
const stream = await graph.stream(
{ messages: [new HumanMessage(message)] },
config
);
for await (const event of stream) {
yield event;
}
}4.4 Agent Service 封装
typescript
// src/services/agent.service.ts
import { createTravelPlannerAgent, streamTravelPlanner } from "@/agent/travel-planner.agent";
import { PrismaClient } from "@prisma/client";
import { AIMessage, HumanMessage, ToolMessage } from "@langchain/core/messages";
import { TripPlanSchema } from "@/agent/tools/trip.tool";
const prisma = new PrismaClient();
export class AgentService {
private agent = createTravelPlannerAgent();
async *chat(conversationId: string, userMessage: string) {
// 1. 保存用户消息
await prisma.message.create({
data: {
conversationId,
role: "user",
content: userMessage,
},
});
// 2. 流式调用 Agent
let fullContent = "";
let toolCalls: any[] = [];
let generatedTrip: any = null;
const stream = streamTravelPlanner(this.agent, userMessage, conversationId);
for await (const event of stream) {
const [messageType, messageData] = event;
if (messageType === "messages") {
for (const msg of messageData) {
if (msg._getType() === "ai") {
const aiMsg = msg as AIMessage;
// 内容增量
if (aiMsg.content) {
const delta = typeof aiMsg.content === "string"
? aiMsg.content
: aiMsg.content.map((c: any) => c.text || "").join("");
fullContent += delta;
yield { type: "content_delta", data: { content: delta } };
}
// 工具调用
if (aiMsg.tool_calls?.length) {
for (const toolCall of aiMsg.tool_calls) {
toolCalls.push(toolCall);
yield {
type: "tool_call",
data: {
id: toolCall.id,
name: toolCall.name,
arguments: toolCall.args,
}
};
// 如果是 generate_trip,解析行程数据
if (toolCall.name === "generate_trip") {
try {
generatedTrip = TripPlanSchema.parse(toolCall.args);
} catch (e) {
console.error("Failed to parse trip:", e);
}
}
}
}
} else if (msg._getType() === "tool") {
const toolMsg = msg as ToolMessage;
yield {
type: "tool_result",
data: {
toolCallId: toolMsg.tool_call_id,
result: JSON.parse(toolMsg.content as string),
},
};
}
}
}
}
// 3. 保存 AI 消息
await prisma.message.create({
data: {
conversationId,
role: "assistant",
content: fullContent,
toolCalls: toolCalls.length > 0 ? toolCalls : undefined,
},
});
// 4. 如果生成了行程,保存到数据库
if (generatedTrip) {
const conversation = await prisma.conversation.findUnique({
where: { id: conversationId },
});
if (conversation) {
const trip = await this.saveTripToDB(conversation.userId, conversationId, generatedTrip);
yield { type: "trip_generated", data: { trip } };
}
}
yield { type: "message_end", data: { content: fullContent } };
}
private async saveTripToDB(userId: string, conversationId: string, tripPlan: any) {
return await prisma.trip.create({
data: {
userId,
conversationId,
title: tripPlan.title,
destination: tripPlan.destination,
startDate: new Date(tripPlan.startDate),
endDate: new Date(tripPlan.endDate),
budget: tripPlan.totalBudget,
status: "draft",
days: {
create: tripPlan.days.map((day: any, index: number) => ({
dayNumber: day.dayNumber || index + 1,
date: new Date(day.date),
activities: {
create: day.activities.map((activity: any, actIndex: number) => ({
type: activity.type,
name: activity.name,
location: activity.location,
startTime: activity.startTime,
endTime: activity.endTime,
duration: activity.duration,
cost: activity.cost,
notes: activity.notes,
order: actIndex,
poiId: activity.poiId,
})),
},
})),
},
},
include: {
days: {
include: {
activities: true,
},
},
},
});
}
}五、外部 API 集成

5.1 高德地图 API
typescript
// src/services/amap.service.ts
import axios from "axios";
import { config } from "@/config";
import { RedisService } from "./redis.service";
interface GeoCodeResponse {
status: string;
geocodes: Array<{
location: string;
formatted_address: string;
adcode: string;
city: string;
}>;
}
interface WeatherResponse {
status: string;
lives: Array<{
temperature: string;
weather: string;
humidity: string;
winddirection: string;
windpower: string;
}>;
forecasts: Array<{
city: string;
casts: Array<{
date: string;
week: string;
dayweather: string;
nightweather: string;
daytemp: string;
nighttemp: string;
}>;
}>;
}
interface POISearchResponse {
status: string;
count: string;
pois: Array<{
id: string;
name: string;
type: string;
typecode: string;
address: string;
location: string;
tel: string;
distance: string;
biz_ext?: {
rating?: string;
cost?: string;
open_time?: string;
};
photos?: Array<{ url: string }>;
}>;
}
export class AmapService {
private baseUrl = "https://restapi.amap.com/v3";
private key = config.amap.apiKey;
private redis = new RedisService();
async geocode(address: string): Promise<GeoCodeResponse> {
const cacheKey = `amap:geocode:${address}`;
const cached = await this.redis.get(cacheKey);
if (cached) return JSON.parse(cached);
const response = await axios.get(`${this.baseUrl}/geocode/geo`, {
params: {
key: this.key,
address,
output: "JSON",
},
});
await this.redis.setEx(cacheKey, 86400, JSON.stringify(response.data)); // 缓存24小时
return response.data;
}
async getWeather(city: string): Promise<WeatherResponse> {
const cacheKey = `amap:weather:${city}`;
const cached = await this.redis.get(cacheKey);
if (cached) return JSON.parse(cached);
// 先获取城市的 adcode
const geocode = await this.geocode(city);
const adcode = geocode.geocodes[0]?.adcode;
if (!adcode) {
throw new Error(`无法找到城市:${city}`);
}
// 查询实时天气
const liveResponse = await axios.get(`${this.baseUrl}/weather/weatherInfo`, {
params: {
key: this.key,
city: adcode,
extensions: "base",
output: "JSON",
},
});
// 查询天气预报
const forecastResponse = await axios.get(`${this.baseUrl}/weather/weatherInfo`, {
params: {
key: this.key,
city: adcode,
extensions: "all",
output: "JSON",
},
});
const result = {
status: liveResponse.data.status,
lives: liveResponse.data.lives,
forecasts: forecastResponse.data.forecasts,
};
await this.redis.setEx(cacheKey, 3600, JSON.stringify(result)); // 缓存1小时
return result;
}
async searchPOI(params: {
keywords?: string;
city: string;
types?: string;
location?: string;
radius?: number;
page_size?: number;
page_num?: number;
}): Promise<POISearchResponse> {
const cacheKey = `amap:poi:${JSON.stringify(params)}`;
const cached = await this.redis.get(cacheKey);
if (cached) return JSON.parse(cached);
const response = await axios.get(`${this.baseUrl}/place/text`, {
params: {
key: this.key,
keywords: params.keywords,
city: params.city,
types: params.types,
offset: params.page_size || 20,
page: params.page_num || 1,
extensions: "all",
output: "JSON",
},
});
await this.redis.setEx(cacheKey, 3600, JSON.stringify(response.data)); // 缓存1小时
return response.data;
}
async searchAround(params: {
location: string;
keywords?: string;
types?: string;
radius?: number;
}): Promise<POISearchResponse> {
const response = await axios.get(`${this.baseUrl}/place/around`, {
params: {
key: this.key,
location: params.location,
keywords: params.keywords,
types: params.types,
radius: params.radius || 3000,
extensions: "all",
output: "JSON",
},
});
return response.data;
}
async planRoute(params: {
origin: string;
destination: string;
mode: "walking" | "driving" | "transit";
city?: string;
}): Promise<any> {
const cacheKey = `amap:route:${params.mode}:${params.origin}:${params.destination}`;
const cached = await this.redis.get(cacheKey);
if (cached) return JSON.parse(cached);
let endpoint = "";
let extraParams: any = {};
switch (params.mode) {
case "walking":
endpoint = "/direction/walking";
break;
case "driving":
endpoint = "/direction/driving";
extraParams = { strategy: 10 }; // 最快路线
break;
case "transit":
endpoint = "/direction/transit/integrated";
extraParams = { city: params.city, cityd: params.city };
break;
}
const response = await axios.get(`${this.baseUrl}${endpoint}`, {
params: {
key: this.key,
origin: params.origin,
destination: params.destination,
output: "JSON",
...extraParams,
},
});
await this.redis.setEx(cacheKey, 3600, JSON.stringify(response.data));
return response.data;
}
}5.2 12306 火车票 API
typescript
// src/services/train.service.ts
import axios from "axios";
import { RedisService } from "./redis.service";
interface StationInfo {
code: string;
name: string;
}
interface TrainTicket {
trainNo: string;
fromStation: string;
toStation: string;
departTime: string;
arriveTime: string;
duration: string;
secondClassPrice: number;
firstClassPrice: number;
businessClassPrice: number;
secondClassSeats: string;
firstClassSeats: string;
}
export class TrainService {
private redis = new RedisService();
private stationMap: Map<string, string> = new Map();
async init() {
// 加载车站代码映射
// 实际项目中从 12306 官方获取或使用本地数据
}
async getStationCode(cityName: string): Promise<string | null> {
// 城市名转车站代码
const cacheKey = `train:station:${cityName}`;
const cached = await this.redis.get(cacheKey);
if (cached) return cached;
// 调用 12306 查询接口或使用本地映射
// 这里简化处理,实际需要更完整的实现
const commonStations: Record<string, string> = {
北京: "BJP",
上海: "SHH",
杭州: "HZH",
南京: "NJH",
广州: "GZQ",
深圳: "SZQ",
成都: "CDW",
重庆: "CQW",
西安: "XAY",
武汉: "WHN",
};
const code = commonStations[cityName];
if (code) {
await this.redis.setEx(cacheKey, 86400 * 30, code);
}
return code || null;
}
async searchTickets(from: string, to: string, date: string): Promise<TrainTicket[]> {
const cacheKey = `train:tickets:${from}:${to}:${date}`;
const cached = await this.redis.get(cacheKey);
if (cached) return JSON.parse(cached);
const fromCode = await this.getStationCode(from);
const toCode = await this.getStationCode(to);
if (!fromCode || !toCode) {
throw new Error(`无法找到车站:${!fromCode ? from : to}`);
}
try {
// 调用 12306 查询接口
// 注意:12306 有反爬机制,实际项目中需要处理
// 这里使用简化的模拟数据
const tickets = await this.queryFromAPI(fromCode, toCode, date);
await this.redis.setEx(cacheKey, 300, JSON.stringify(tickets)); // 缓存5分钟
return tickets;
} catch (error) {
console.error("12306 查询失败:", error);
// 返回模拟数据用于演示
return this.getMockTickets(from, to, date);
}
}
private async queryFromAPI(fromCode: string, toCode: string, date: string): Promise<TrainTicket[]> {
// 实际 12306 API 调用
// 由于 12306 限制,这里返回模拟数据
throw new Error("API not implemented");
}
private getMockTickets(from: string, to: string, date: string): TrainTicket[] {
// 模拟数据用于开发测试
return [
{
trainNo: "G7501",
fromStation: from,
toStation: to,
departTime: "08:00",
arriveTime: "09:30",
duration: "1小时30分",
secondClassPrice: 73,
firstClassPrice: 117,
businessClassPrice: 219,
secondClassSeats: "有票",
firstClassSeats: "有票",
},
{
trainNo: "G7503",
fromStation: from,
toStation: to,
departTime: "10:00",
arriveTime: "11:25",
duration: "1小时25分",
secondClassPrice: 73,
firstClassPrice: 117,
businessClassPrice: 219,
secondClassSeats: "有票",
firstClassSeats: "3张",
},
{
trainNo: "D3101",
fromStation: from,
toStation: to,
departTime: "12:30",
arriveTime: "14:20",
duration: "1小时50分",
secondClassPrice: 56,
firstClassPrice: 89,
businessClassPrice: 0,
secondClassSeats: "有票",
firstClassSeats: "有票",
},
];
}
}5.3 Redis 服务
typescript
// src/services/redis.service.ts
import { createClient, RedisClientType } from "redis";
import { config } from "@/config";
import { logger } from "@/utils/logger";
export class RedisService {
private client: RedisClientType | null = null;
private memoryCache: Map<string, { value: string; expireAt: number }> = new Map();
private isConnected = false;
async connect() {
if (this.isConnected) return;
try {
this.client = createClient({
url: config.redis.url,
});
this.client.on("error", (err) => {
logger.error("Redis 连接错误:", err);
this.isConnected = false;
});
await this.client.connect();
this.isConnected = true;
logger.info("Redis 连接成功");
} catch (error) {
logger.warn("Redis 连接失败,使用内存缓存:", error);
this.isConnected = false;
}
}
async get(key: string): Promise<string | null> {
if (this.isConnected && this.client) {
try {
return await this.client.get(key);
} catch (error) {
logger.error("Redis get 错误:", error);
}
}
// 降级到内存缓存
const cached = this.memoryCache.get(key);
if (cached && cached.expireAt > Date.now()) {
return cached.value;
}
this.memoryCache.delete(key);
return null;
}
async setEx(key: string, seconds: number, value: string): Promise<void> {
if (this.isConnected && this.client) {
try {
await this.client.setEx(key, seconds, value);
return;
} catch (error) {
logger.error("Redis setEx 错误:", error);
}
}
// 降级到内存缓存
this.memoryCache.set(key, {
value,
expireAt: Date.now() + seconds * 1000,
});
}
async del(key: string): Promise<void> {
if (this.isConnected && this.client) {
try {
await this.client.del(key);
} catch (error) {
logger.error("Redis del 错误:", error);
}
}
this.memoryCache.delete(key);
}
async disconnect() {
if (this.client) {
await this.client.disconnect();
this.isConnected = false;
}
}
}六、后端项目结构
travel-planner-backend/
├── prisma/
│ ├── schema.prisma # 数据库模型定义
│ └── migrations/ # 数据库迁移
├── src/
│ ├── agent/ # LangChain Multi-Agent 系统
│ │ ├── tools/ # 工具定义(8个工具)
│ │ │ ├── weather.tool.ts # 天气查询
│ │ │ ├── transport.tool.ts # 火车票查询
│ │ │ ├── hotel.tool.ts # 酒店搜索
│ │ │ ├── attraction.tool.ts # 景点搜索 ⭐
│ │ │ ├── restaurant.tool.ts # 餐厅搜索 ⭐
│ │ │ ├── route.tool.ts # 路线规划 ⭐
│ │ │ ├── poi.tool.ts # 通用 POI 搜索
│ │ │ ├── date.tool.ts # 日期获取
│ │ │ └── index.ts
│ │ ├── coordinator.agent.ts # 协调器 Agent ⭐
│ │ ├── info-collector.agent.ts # 信息收集 Sub-Agent ⭐
│ │ ├── trip-planner.agent.ts # 行程规划 Sub-Agent ⭐
│ │ ├── trip-adjuster.agent.ts # 行程调整 Sub-Agent ⭐
│ │ ├── travel-planner.graph.ts # 完整 Graph 组装 ⭐
│ │ └── index.ts # 状态定义
│ ├── config/ # 配置
│ │ └── index.ts
│ ├── controllers/ # 控制器
│ │ ├── auth.controller.ts
│ │ ├── chat.controller.ts
│ │ ├── conversation.controller.ts
│ │ └── trip.controller.ts
│ ├── middlewares/ # 中间件
│ │ ├── auth.middleware.ts
│ │ ├── error.middleware.ts
│ │ └── validate.middleware.ts
│ ├── routes/ # 路由
│ │ ├── auth.routes.ts
│ │ ├── chat.routes.ts
│ │ ├── conversation.routes.ts
│ │ ├── trip.routes.ts
│ │ └── index.ts
│ ├── services/ # 服务层
│ │ ├── agent.service.ts
│ │ ├── amap.service.ts
│ │ ├── auth.service.ts
│ │ ├── conversation.service.ts
│ │ ├── redis.service.ts
│ │ ├── train.service.ts
│ │ ├── trip.service.ts
│ │ └── user.service.ts
│ ├── schemas/ # Zod Schema
│ │ ├── auth.schema.ts
│ │ ├── chat.schema.ts
│ │ └── trip.schema.ts
│ ├── types/ # TypeScript 类型
│ │ └── index.ts
│ ├── utils/ # 工具函数
│ │ ├── jwt.ts
│ │ ├── logger.ts
│ │ └── response.ts
│ ├── docs/ # API 文档
│ │ └── swagger.ts
│ └── app.ts # 应用入口
├── .env # 环境变量
├── .env.example
├── package.json
├── tsconfig.json
└── README.md6.1 核心配置文件
typescript
// src/config/index.ts
import { z } from "zod";
import dotenv from "dotenv";
dotenv.config();
const envSchema = z.object({
NODE_ENV: z.enum(["development", "production", "test"]).default("development"),
PORT: z.string().default("3000"),
DATABASE_URL: z.string(),
REDIS_URL: z.string().optional(),
JWT_SECRET: z.string(),
JWT_ACCESS_EXPIRES: z.string().default("15m"),
JWT_REFRESH_EXPIRES: z.string().default("7d"),
OPENAI_API_KEY: z.string(),
OPENAI_BASE_URL: z.string().optional(),
OPENAI_MODEL: z.string().default("gpt-4o"),
AMAP_API_KEY: z.string(),
});
const env = envSchema.parse(process.env);
export const config = {
env: env.NODE_ENV,
port: parseInt(env.PORT, 10),
database: {
url: env.DATABASE_URL,
},
redis: {
url: env.REDIS_URL,
},
jwt: {
secret: env.JWT_SECRET,
accessExpiresIn: env.JWT_ACCESS_EXPIRES,
refreshExpiresIn: env.JWT_REFRESH_EXPIRES,
},
openai: {
apiKey: env.OPENAI_API_KEY,
baseUrl: env.OPENAI_BASE_URL,
model: env.OPENAI_MODEL,
},
amap: {
apiKey: env.AMAP_API_KEY,
},
};6.2 应用入口
typescript
// src/app.ts
import express from "express";
import cors from "cors";
import helmet from "helmet";
import compression from "compression";
import { config } from "./config";
import { logger } from "./utils/logger";
import { errorMiddleware } from "./middlewares/error.middleware";
import routes from "./routes";
import { setupSwagger } from "./docs/swagger";
const app = express();
// 中间件
app.use(helmet());
app.use(cors());
app.use(compression());
app.use(express.json());
app.use(express.urlencoded({ extended: true }));
// API 文档
setupSwagger(app);
// 路由
app.use("/api", routes);
// 错误处理
app.use(errorMiddleware);
// 启动服务器
app.listen(config.port, () => {
logger.info(`服务器启动成功: http://localhost:${config.port}`);
logger.info(`API 文档: http://localhost:${config.port}/docs`);
});
export default app;6.3 聊天控制器(SSE 流式)
typescript
// src/controllers/chat.controller.ts
import { Request, Response } from "express";
import { AgentService } from "@/services/agent.service";
const agentService = new AgentService();
export const chatStream = async (req: Request, res: Response) => {
const { conversationId, message } = req.query;
if (!conversationId || !message) {
res.status(400).json({ error: "缺少必要参数" });
return;
}
// 设置 SSE 响应头
res.setHeader("Content-Type", "text/event-stream");
res.setHeader("Cache-Control", "no-cache");
res.setHeader("Connection", "keep-alive");
res.setHeader("X-Accel-Buffering", "no");
try {
const stream = agentService.chat(
conversationId as string,
message as string
);
for await (const event of stream) {
const data = JSON.stringify(event.data);
res.write(`event: ${event.type}\n`);
res.write(`data: ${data}\n\n`);
}
res.write("event: done\n");
res.write("data: {}\n\n");
res.end();
} catch (error) {
console.error("Chat stream error:", error);
res.write(`event: error\n`);
res.write(`data: ${JSON.stringify({ error: "处理失败" })}\n\n`);
res.end();
}
};七、前端项目结构
travel-planner-frontend/
├── public/
│ └── favicon.ico
├── src/
│ ├── api/ # API 请求
│ │ ├── auth.api.ts
│ │ ├── chat.api.ts
│ │ ├── conversation.api.ts
│ │ ├── trip.api.ts
│ │ └── index.ts
│ ├── assets/ # 静态资源
│ │ ├── images/
│ │ └── styles/
│ │ └── global.less
│ ├── components/ # 通用组件
│ │ ├── ChatMessage/
│ │ │ ├── index.tsx
│ │ │ └── index.module.less
│ │ ├── ToolCallCard/
│ │ │ ├── index.tsx
│ │ │ └── index.module.less
│ │ ├── TripCard/
│ │ │ ├── index.tsx
│ │ │ └── index.module.less
│ │ ├── TripTimeline/
│ │ │ ├── index.tsx
│ │ │ └── index.module.less
│ │ └── Layout/
│ │ ├── index.tsx
│ │ └── index.module.less
│ ├── hooks/ # 自定义 Hooks
│ │ ├── useChat.ts
│ │ ├── useTrip.ts
│ │ └── useAuth.ts
│ ├── pages/ # 页面组件
│ │ ├── Chat/
│ │ │ ├── index.tsx
│ │ │ └── index.module.less
│ │ ├── Trip/
│ │ │ ├── index.tsx
│ │ │ ├── TripDetail.tsx
│ │ │ ├── TripEdit.tsx
│ │ │ └── index.module.less
│ │ ├── History/
│ │ │ ├── index.tsx
│ │ │ └── index.module.less
│ │ ├── Login/
│ │ │ ├── index.tsx
│ │ │ └── index.module.less
│ │ └── Register/
│ │ ├── index.tsx
│ │ └── index.module.less
│ ├── stores/ # Zustand 状态
│ │ ├── auth.store.ts
│ │ ├── chat.store.ts
│ │ ├── trip.store.ts
│ │ └── index.ts
│ ├── types/ # TypeScript 类型
│ │ ├── api.types.ts
│ │ ├── chat.types.ts
│ │ └── trip.types.ts
│ ├── utils/ # 工具函数
│ │ ├── request.ts
│ │ ├── storage.ts
│ │ └── date.ts
│ ├── router/ # 路由配置
│ │ └── index.tsx
│ ├── App.tsx
│ ├── main.tsx
│ └── vite-env.d.ts
├── .eslintrc.js
├── .prettierrc
├── .stylelintrc.js
├── index.html
├── package.json
├── tsconfig.json
├── tsconfig.node.json
└── vite.config.ts7.1 Zustand Store
typescript
// src/stores/chat.store.ts
import { create } from "zustand";
import { immer } from "zustand/middleware/immer";
interface Message {
id: string;
role: "user" | "assistant" | "tool";
content: string;
toolCalls?: Array<{
id: string;
name: string;
arguments: Record<string, unknown>;
result?: unknown;
status: "pending" | "running" | "completed" | "error";
}>;
createdAt: string;
}
interface ChatState {
conversations: Array<{
id: string;
title: string;
messages: Message[];
}>;
currentConversationId: string | null;
isLoading: boolean;
streamingContent: string;
currentToolCall: {
id: string;
name: string;
arguments: string;
status: string;
} | null;
// Actions
setCurrentConversation: (id: string | null) => void;
addMessage: (conversationId: string, message: Message) => void;
updateStreamingContent: (content: string) => void;
appendStreamingContent: (delta: string) => void;
setToolCall: (toolCall: ChatState["currentToolCall"]) => void;
updateToolCallResult: (toolCallId: string, result: unknown) => void;
clearStreaming: () => void;
setLoading: (loading: boolean) => void;
}
export const useChatStore = create<ChatState>()(
immer((set) => ({
conversations: [],
currentConversationId: null,
isLoading: false,
streamingContent: "",
currentToolCall: null,
setCurrentConversation: (id) =>
set((state) => {
state.currentConversationId = id;
}),
addMessage: (conversationId, message) =>
set((state) => {
const conversation = state.conversations.find(
(c) => c.id === conversationId
);
if (conversation) {
conversation.messages.push(message);
}
}),
updateStreamingContent: (content) =>
set((state) => {
state.streamingContent = content;
}),
appendStreamingContent: (delta) =>
set((state) => {
state.streamingContent += delta;
}),
setToolCall: (toolCall) =>
set((state) => {
state.currentToolCall = toolCall;
}),
updateToolCallResult: (toolCallId, result) =>
set((state) => {
if (state.currentToolCall?.id === toolCallId) {
state.currentToolCall.status = "completed";
}
}),
clearStreaming: () =>
set((state) => {
state.streamingContent = "";
state.currentToolCall = null;
}),
setLoading: (loading) =>
set((state) => {
state.isLoading = loading;
}),
}))
);7.2 聊天 Hook(SSE 处理)

typescript
// src/hooks/useChat.ts
import { useCallback, useRef } from "react";
import { useChatStore } from "@/stores/chat.store";
import { useTripStore } from "@/stores/trip.store";
import { API_BASE_URL } from "@/api";
export function useChat() {
const {
currentConversationId,
isLoading,
streamingContent,
currentToolCall,
setLoading,
addMessage,
appendStreamingContent,
setToolCall,
updateToolCallResult,
clearStreaming,
} = useChatStore();
const { setCurrentTrip } = useTripStore();
const abortControllerRef = useRef<AbortController | null>(null);
const sendMessage = useCallback(
async (message: string) => {
if (!currentConversationId || isLoading) return;
// 添加用户消息
addMessage(currentConversationId, {
id: `user_${Date.now()}`,
role: "user",
content: message,
createdAt: new Date().toISOString(),
});
setLoading(true);
clearStreaming();
// 创建 AbortController
abortControllerRef.current = new AbortController();
try {
const url = new URL(`${API_BASE_URL}/chat/stream`);
url.searchParams.set("conversationId", currentConversationId);
url.searchParams.set("message", message);
const response = await fetch(url.toString(), {
method: "GET",
headers: {
Authorization: `Bearer ${localStorage.getItem("accessToken")}`,
},
signal: abortControllerRef.current.signal,
});
if (!response.ok) {
throw new Error("请求失败");
}
const reader = response.body?.getReader();
const decoder = new TextDecoder();
if (!reader) {
throw new Error("无法读取响应流");
}
let buffer = "";
let assistantMessageId = `assistant_${Date.now()}`;
let fullContent = "";
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
// 解析 SSE 事件
const lines = buffer.split("\n");
buffer = lines.pop() || "";
let eventType = "";
for (const line of lines) {
if (line.startsWith("event: ")) {
eventType = line.slice(7);
} else if (line.startsWith("data: ")) {
const data = JSON.parse(line.slice(6));
switch (eventType) {
case "content_delta":
fullContent += data.content || "";
appendStreamingContent(data.content || "");
break;
case "tool_call":
setToolCall({
id: data.id,
name: data.name,
arguments: JSON.stringify(data.arguments, null, 2),
status: "running",
});
break;
case "tool_result":
updateToolCallResult(data.toolCallId, data.result);
break;
case "trip_generated":
setCurrentTrip(data.trip);
break;
case "message_end":
// 添加完整的 AI 消息
addMessage(currentConversationId, {
id: assistantMessageId,
role: "assistant",
content: fullContent,
createdAt: new Date().toISOString(),
});
break;
case "error":
console.error("Stream error:", data.error);
break;
}
}
}
}
} catch (error) {
if ((error as Error).name !== "AbortError") {
console.error("Chat error:", error);
}
} finally {
setLoading(false);
clearStreaming();
abortControllerRef.current = null;
}
},
[currentConversationId, isLoading]
);
const stopGeneration = useCallback(() => {
if (abortControllerRef.current) {
abortControllerRef.current.abort();
}
}, []);
return {
sendMessage,
stopGeneration,
isLoading,
streamingContent,
currentToolCall,
};
}7.3 聊天页面
tsx
// src/pages/Chat/index.tsx
import React, { useState, useRef, useEffect } from "react";
import { Input, Button, Spin, Card } from "antd";
import { SendOutlined, StopOutlined } from "@ant-design/icons";
import { useChat } from "@/hooks/useChat";
import { useChatStore } from "@/stores/chat.store";
import { useTripStore } from "@/stores/trip.store";
import ChatMessage from "@/components/ChatMessage";
import ToolCallCard from "@/components/ToolCallCard";
import TripCard from "@/components/TripCard";
import styles from "./index.module.less";
const ChatPage: React.FC = () => {
const [inputValue, setInputValue] = useState("");
const messagesEndRef = useRef<HTMLDivElement>(null);
const { conversations, currentConversationId, streamingContent, currentToolCall } = useChatStore();
const { currentTrip } = useTripStore();
const { sendMessage, stopGeneration, isLoading } = useChat();
const currentConversation = conversations.find(
(c) => c.id === currentConversationId
);
const handleSend = () => {
if (!inputValue.trim() || isLoading) return;
sendMessage(inputValue.trim());
setInputValue("");
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
handleSend();
}
};
// 自动滚动到底部
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
}, [currentConversation?.messages, streamingContent]);
return (
<div className={styles.chatPage}>
<div className={styles.messageList}>
{/* 欢迎消息 */}
{!currentConversation?.messages.length && (
<div className={styles.welcome}>
<h2>🌴 欢迎使用 AI 旅游规划师</h2>
<p>告诉我你想去哪里,我来帮你规划完美的旅行!</p>
<div className={styles.examples}>
<Card
size="small"
hoverable
onClick={() => setInputValue("我想去杭州玩3天,预算3000")}
>
🏞️ 我想去杭州玩3天,预算3000
</Card>
<Card
size="small"
hoverable
onClick={() => setInputValue("下周末想去上海迪士尼,两个人")}
>
🎢 下周末想去上海迪士尼,两个人
</Card>
<Card
size="small"
hoverable
onClick={() => setInputValue("帮我规划一个北京5日游,想看故宫长城")}
>
🏛️ 帮我规划一个北京5日游
</Card>
</div>
</div>
)}
{/* 消息列表 */}
{currentConversation?.messages.map((message) => (
<ChatMessage key={message.id} message={message} />
))}
{/* 流式内容 */}
{streamingContent && (
<ChatMessage
message={{
id: "streaming",
role: "assistant",
content: streamingContent,
createdAt: new Date().toISOString(),
}}
isStreaming
/>
)}
{/* 工具调用卡片 */}
{currentToolCall && (
<ToolCallCard toolCall={currentToolCall} />
)}
{/* 生成的行程卡片 */}
{currentTrip && (
<TripCard trip={currentTrip} />
)}
<div ref={messagesEndRef} />
</div>
{/* 输入区域 */}
<div className={styles.inputArea}>
<Input.TextArea
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="告诉我你的旅行计划..."
autoSize={{ minRows: 1, maxRows: 4 }}
disabled={isLoading}
/>
{isLoading ? (
<Button
type="primary"
danger
icon={<StopOutlined />}
onClick={stopGeneration}
>
停止
</Button>
) : (
<Button
type="primary"
icon={<SendOutlined />}
onClick={handleSend}
disabled={!inputValue.trim()}
>
发送
</Button>
)}
</div>
</div>
);
};
export default ChatPage;7.4 工具调用卡片组件
tsx
// src/components/ToolCallCard/index.tsx
import React from "react";
import { Card, Spin, Tag } from "antd";
import {
CloudOutlined,
TrainOutlined,
HomeOutlined,
EnvironmentOutlined,
CalendarOutlined,
FileTextOutlined,
} from "@ant-design/icons";
import styles from "./index.module.less";
interface ToolCallCardProps {
toolCall: {
id: string;
name: string;
arguments: string;
status: string;
};
}
const toolConfig: Record<string, { icon: React.ReactNode; label: string; color: string }> = {
get_weather: {
icon: <CloudOutlined />,
label: "查询天气",
color: "#1890ff",
},
search_train: {
icon: <TrainOutlined />,
label: "搜索火车票",
color: "#52c41a",
},
search_hotel: {
icon: <HomeOutlined />,
label: "搜索酒店",
color: "#faad14",
},
search_poi: {
icon: <EnvironmentOutlined />,
label: "搜索景点",
color: "#eb2f96",
},
get_current_date: {
icon: <CalendarOutlined />,
label: "获取日期",
color: "#722ed1",
},
generate_trip: {
icon: <FileTextOutlined />,
label: "生成行程",
color: "#13c2c2",
},
};
const ToolCallCard: React.FC<ToolCallCardProps> = ({ toolCall }) => {
const config = toolConfig[toolCall.name] || {
icon: <FileTextOutlined />,
label: toolCall.name,
color: "#666",
};
const isRunning = toolCall.status === "running";
return (
<Card className={styles.toolCallCard} size="small">
<div className={styles.header}>
<span className={styles.icon} style={{ color: config.color }}>
{config.icon}
</span>
<span className={styles.label}>{config.label}</span>
{isRunning ? (
<Spin size="small" />
) : (
<Tag color="success">完成</Tag>
)}
</div>
{toolCall.arguments && (
<pre className={styles.arguments}>
{toolCall.arguments}
</pre>
)}
</Card>
);
};
export default ToolCallCard;7.5 行程卡片组件
tsx
// src/components/TripCard/index.tsx
import React from "react";
import { Card, Button, Tag, Timeline, Tooltip } from "antd";
import {
CalendarOutlined,
EnvironmentOutlined,
DollarOutlined,
EditOutlined,
CheckOutlined,
} from "@ant-design/icons";
import { useNavigate } from "react-router-dom";
import type { Trip } from "@/types/trip.types";
import styles from "./index.module.less";
interface TripCardProps {
trip: Trip;
}
const activityTypeConfig: Record<string, { color: string; label: string }> = {
attraction: { color: "#1890ff", label: "景点" },
restaurant: { color: "#fa8c16", label: "餐厅" },
transport: { color: "#52c41a", label: "交通" },
hotel: { color: "#722ed1", label: "住宿" },
free: { color: "#bfbfbf", label: "自由" },
};
const TripCard: React.FC<TripCardProps> = ({ trip }) => {
const navigate = useNavigate();
return (
<Card className={styles.tripCard}>
<div className={styles.header}>
<h3>{trip.title}</h3>
<Tag color={trip.status === "confirmed" ? "success" : "default"}>
{trip.status === "confirmed" ? "已确认" : "草稿"}
</Tag>
</div>
<div className={styles.meta}>
<span>
<EnvironmentOutlined /> {trip.destination}
</span>
<span>
<CalendarOutlined /> {trip.startDate} - {trip.endDate}
</span>
<span>
<DollarOutlined /> 预算 ¥{trip.budget}
</span>
</div>
<div className={styles.days}>
{trip.days.map((day) => (
<div key={day.id} className={styles.day}>
<div className={styles.dayHeader}>
<span className={styles.dayNumber}>Day {day.dayNumber}</span>
<span className={styles.dayDate}>{day.date}</span>
</div>
<Timeline
className={styles.timeline}
items={day.activities.map((activity) => ({
color: activityTypeConfig[activity.type]?.color || "#1890ff",
children: (
<div className={styles.activity}>
<span className={styles.time}>
{activity.startTime} - {activity.endTime}
</span>
<span className={styles.name}>{activity.name}</span>
{activity.cost > 0 && (
<Tag size="small">¥{activity.cost}</Tag>
)}
</div>
),
}))}
/>
</div>
))}
</div>
<div className={styles.actions}>
<Button
icon={<EditOutlined />}
onClick={() => navigate(`/trip/${trip.id}/edit`)}
>
编辑行程
</Button>
{trip.status === "draft" && (
<Button
type="primary"
icon={<CheckOutlined />}
onClick={() => {/* 确认行程 */}}
>
确认行程
</Button>
)}
</div>
</Card>
);
};
export default TripCard;八、行程编辑页面

8.1 可拖拽行程编辑
tsx
// src/pages/Trip/TripEdit.tsx
import React, { useState, useEffect } from "react";
import { useParams, useNavigate } from "react-router-dom";
import {
Card,
Button,
Input,
DatePicker,
InputNumber,
message,
Spin,
Modal,
} from "antd";
import {
DragDropContext,
Droppable,
Draggable,
DropResult,
} from "@hello-pangea/dnd";
import {
PlusOutlined,
DeleteOutlined,
SaveOutlined,
ArrowLeftOutlined,
} from "@ant-design/icons";
import dayjs from "dayjs";
import { tripApi } from "@/api/trip.api";
import type { Trip, TripDay, TripActivity } from "@/types/trip.types";
import ActivityModal from "./components/ActivityModal";
import styles from "./index.module.less";
const TripEdit: React.FC = () => {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const [trip, setTrip] = useState<Trip | null>(null);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [activityModal, setActivityModal] = useState<{
visible: boolean;
dayId: string;
activity?: TripActivity;
}>({ visible: false, dayId: "" });
useEffect(() => {
loadTrip();
}, [id]);
const loadTrip = async () => {
if (!id) return;
try {
const data = await tripApi.getTrip(id);
setTrip(data);
} catch (error) {
message.error("加载行程失败");
} finally {
setLoading(false);
}
};
const handleDragEnd = (result: DropResult) => {
if (!result.destination || !trip) return;
const { source, destination, type } = result;
if (type === "day") {
// 拖拽天
const newDays = Array.from(trip.days);
const [removed] = newDays.splice(source.index, 1);
newDays.splice(destination.index, 0, removed);
// 更新 dayNumber
newDays.forEach((day, index) => {
day.dayNumber = index + 1;
});
setTrip({ ...trip, days: newDays });
} else if (type === "activity") {
// 拖拽活动
const sourceDayIndex = trip.days.findIndex(
(d) => d.id === source.droppableId
);
const destDayIndex = trip.days.findIndex(
(d) => d.id === destination.droppableId
);
if (sourceDayIndex === -1 || destDayIndex === -1) return;
const newDays = Array.from(trip.days);
if (sourceDayIndex === destDayIndex) {
// 同一天内拖拽
const activities = Array.from(newDays[sourceDayIndex].activities);
const [removed] = activities.splice(source.index, 1);
activities.splice(destination.index, 0, removed);
// 更新顺序
activities.forEach((act, index) => {
act.order = index;
});
newDays[sourceDayIndex].activities = activities;
} else {
// 跨天拖拽
const sourceActivities = Array.from(newDays[sourceDayIndex].activities);
const destActivities = Array.from(newDays[destDayIndex].activities);
const [removed] = sourceActivities.splice(source.index, 1);
destActivities.splice(destination.index, 0, removed);
// 更新顺序
sourceActivities.forEach((act, index) => {
act.order = index;
});
destActivities.forEach((act, index) => {
act.order = index;
});
newDays[sourceDayIndex].activities = sourceActivities;
newDays[destDayIndex].activities = destActivities;
}
setTrip({ ...trip, days: newDays });
}
};
const handleSave = async () => {
if (!trip) return;
setSaving(true);
try {
await tripApi.updateTrip(trip.id, {
title: trip.title,
startDate: trip.startDate,
endDate: trip.endDate,
budget: trip.budget,
days: trip.days.map((day) => ({
id: day.id,
dayNumber: day.dayNumber,
date: day.date,
activities: day.activities.map((act) => ({
id: act.id,
type: act.type,
name: act.name,
location: act.location,
startTime: act.startTime,
endTime: act.endTime,
duration: act.duration,
cost: act.cost,
notes: act.notes,
order: act.order,
poiId: act.poiId,
})),
})),
});
message.success("保存成功");
} catch (error) {
message.error("保存失败");
} finally {
setSaving(false);
}
};
const handleAddActivity = (dayId: string) => {
setActivityModal({ visible: true, dayId });
};
const handleEditActivity = (dayId: string, activity: TripActivity) => {
setActivityModal({ visible: true, dayId, activity });
};
const handleDeleteActivity = (dayId: string, activityId: string) => {
if (!trip) return;
Modal.confirm({
title: "确认删除",
content: "确定要删除这个活动吗?",
onOk: () => {
const newDays = trip.days.map((day) => {
if (day.id === dayId) {
return {
...day,
activities: day.activities.filter((a) => a.id !== activityId),
};
}
return day;
});
setTrip({ ...trip, days: newDays });
},
});
};
const handleActivitySave = (dayId: string, activity: TripActivity) => {
if (!trip) return;
const newDays = trip.days.map((day) => {
if (day.id === dayId) {
const existingIndex = day.activities.findIndex((a) => a.id === activity.id);
if (existingIndex > -1) {
// 编辑
const newActivities = [...day.activities];
newActivities[existingIndex] = activity;
return { ...day, activities: newActivities };
} else {
// 新增
return {
...day,
activities: [...day.activities, { ...activity, order: day.activities.length }],
};
}
}
return day;
});
setTrip({ ...trip, days: newDays });
setActivityModal({ visible: false, dayId: "" });
};
if (loading) {
return (
<div className={styles.loading}>
<Spin size="large" />
</div>
);
}
if (!trip) {
return <div>行程不存在</div>;
}
return (
<div className={styles.tripEdit}>
<div className={styles.header}>
<Button
icon={<ArrowLeftOutlined />}
onClick={() => navigate(-1)}
>
返回
</Button>
<h2>编辑行程</h2>
<Button
type="primary"
icon={<SaveOutlined />}
loading={saving}
onClick={handleSave}
>
保存
</Button>
</div>
<Card className={styles.basicInfo}>
<div className={styles.formRow}>
<label>行程标题</label>
<Input
value={trip.title}
onChange={(e) => setTrip({ ...trip, title: e.target.value })}
/>
</div>
<div className={styles.formRow}>
<label>出行日期</label>
<DatePicker.RangePicker
value={[dayjs(trip.startDate), dayjs(trip.endDate)]}
onChange={(dates) => {
if (dates) {
setTrip({
...trip,
startDate: dates[0]!.format("YYYY-MM-DD"),
endDate: dates[1]!.format("YYYY-MM-DD"),
});
}
}}
/>
</div>
<div className={styles.formRow}>
<label>预算</label>
<InputNumber
value={trip.budget}
onChange={(value) => setTrip({ ...trip, budget: value || 0 })}
prefix="¥"
min={0}
/>
</div>
</Card>
<DragDropContext onDragEnd={handleDragEnd}>
<Droppable droppableId="days" type="day">
{(provided) => (
<div
ref={provided.innerRef}
{...provided.droppableProps}
className={styles.days}
>
{trip.days.map((day, dayIndex) => (
<Draggable key={day.id} draggableId={day.id} index={dayIndex}>
{(provided) => (
<Card
ref={provided.innerRef}
{...provided.draggableProps}
className={styles.dayCard}
>
<div
className={styles.dayHeader}
{...provided.dragHandleProps}
>
<span className={styles.dayNumber}>
Day {day.dayNumber}
</span>
<span className={styles.dayDate}>{day.date}</span>
</div>
<Droppable droppableId={day.id} type="activity">
{(provided) => (
<div
ref={provided.innerRef}
{...provided.droppableProps}
className={styles.activities}
>
{day.activities.map((activity, actIndex) => (
<Draggable
key={activity.id}
draggableId={activity.id}
index={actIndex}
>
{(provided) => (
<div
ref={provided.innerRef}
{...provided.draggableProps}
{...provided.dragHandleProps}
className={styles.activityItem}
>
<span className={styles.time}>
{activity.startTime} - {activity.endTime}
</span>
<span className={styles.name}>
{activity.name}
</span>
<div className={styles.actions}>
<Button
type="link"
size="small"
onClick={() =>
handleEditActivity(day.id, activity)
}
>
编辑
</Button>
<Button
type="link"
size="small"
danger
icon={<DeleteOutlined />}
onClick={() =>
handleDeleteActivity(day.id, activity.id)
}
/>
</div>
</div>
)}
</Draggable>
))}
{provided.placeholder}
</div>
)}
</Droppable>
<Button
type="dashed"
icon={<PlusOutlined />}
className={styles.addActivity}
onClick={() => handleAddActivity(day.id)}
>
添加活动
</Button>
</Card>
)}
</Draggable>
))}
{provided.placeholder}
</div>
)}
</Droppable>
</DragDropContext>
<ActivityModal
visible={activityModal.visible}
dayId={activityModal.dayId}
activity={activityModal.activity}
onSave={handleActivitySave}
onCancel={() => setActivityModal({ visible: false, dayId: "" })}
/>
</div>
);
};
export default TripEdit;九、环境变量配置
9.1 后端环境变量
bash
# .env.example
# 服务器配置
NODE_ENV=development
PORT=3000
# 数据库配置
DATABASE_URL="mysql://root:password@localhost:3306/travel_planner"
# Redis 配置(可选)
REDIS_URL="redis://localhost:6379"
# JWT 配置
JWT_SECRET="your-super-secret-jwt-key-change-in-production"
JWT_ACCESS_EXPIRES="15m"
JWT_REFRESH_EXPIRES="7d"
# OpenAI 配置
OPENAI_API_KEY="sk-your-openai-api-key"
OPENAI_BASE_URL="https://api.openai.com/v1"
OPENAI_MODEL="gpt-4o"
# 高德地图 API
AMAP_API_KEY="your-amap-api-key"9.2 前端环境变量
bash
# .env.development
VITE_API_BASE_URL=http://localhost:3000/api
# .env.production
VITE_API_BASE_URL=https://your-domain.com/api十、部署说明
10.1 Docker Compose
yaml
# docker-compose.yml
version: '3.8'
services:
mysql:
image: mysql:8.0
container_name: travel-planner-mysql
environment:
MYSQL_ROOT_PASSWORD: rootpassword
MYSQL_DATABASE: travel_planner
ports:
- "3306:3306"
volumes:
- mysql_data:/var/lib/mysql
redis:
image: redis:7-alpine
container_name: travel-planner-redis
ports:
- "6379:6379"
backend:
build:
context: ./travel-planner-backend
dockerfile: Dockerfile
container_name: travel-planner-backend
environment:
- NODE_ENV=production
- DATABASE_URL=mysql://root:rootpassword@mysql:3306/travel_planner
- REDIS_URL=redis://redis:6379
ports:
- "3000:3000"
depends_on:
- mysql
- redis
frontend:
build:
context: ./travel-planner-frontend
dockerfile: Dockerfile
container_name: travel-planner-frontend
ports:
- "80:80"
depends_on:
- backend
volumes:
mysql_data:10.2 后端 Dockerfile
dockerfile
# travel-planner-backend/Dockerfile
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
COPY prisma ./prisma/
RUN npm ci
COPY . .
RUN npm run build
RUN npx prisma generate
FROM node:20-alpine AS runner
WORKDIR /app
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/prisma ./prisma
COPY --from=builder /app/package*.json ./
EXPOSE 3000
CMD ["npm", "run", "start:prod"]10.3 前端 Dockerfile
dockerfile
# travel-planner-frontend/Dockerfile
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM nginx:alpine
COPY --from=builder /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]十一、总结
技术要点回顾
| 模块 | 技术栈 | 说明 |
|---|---|---|
| Multi-Agent | LangGraph StateGraph | Router + Sub-Agent 混合架构 |
| Coordinator | withStructuredOutput | 意图识别 + 参数提取 + 路由 |
| Info Collector | createReactAgent | 8 个工具并行信息收集 |
| Trip Planner | withStructuredOutput | 结构化行程生成 |
| Trip Adjuster | createReactAgent | 行程修改 + 冲突解决 |
| 工具调用 | @langchain/core/tools | 天气、交通、酒店、景点、餐厅、路线、日期、POI |
| 流式输出 | SSE | 实时展示 AI 思考过程和工具调用 |
| 外部API | 高德地图 + 12306 | 天气、POI、路线规划、火车票 |
| 缓存降级 | Redis + 内存 | Redis 不可用时自动降级 |
| 前端状态 | Zustand | 简洁的状态管理 |
| 拖拽编辑 | @hello-pangea/dnd | 可视化行程编辑 |
架构亮点

┌─────────────────────────────────────────────────────────────────────┐
│ 项目架构亮点 │
├─────────────────────────────────────────────────────────────────────┤
│ │
│ 1. Router + Sub-Agent 混合架构 │
│ ┌──────────────┐ │
│ │ Coordinator │ → 意图识别 → 路由分发 │
│ └──────┬───────┘ │
│ ├──────────────┬──────────────┐ │
│ ▼ ▼ ▼ │
│ ┌────────────┐ ┌────────────┐ ┌────────────┐ │
│ │Info Collect│ │Trip Planner│ │Trip Adjuster│ │
│ │ (工具调用) │ │ (LLM推理) │ │ (工具+推理)│ │
│ └────────────┘ └────────────┘ └────────────┘ │
│ │
│ 2. 行程生成是 Agent 而非 Tool │
│ • 复杂推理:综合天气、交通、地理位置等多因素 │
│ • 创造性输出:安排主题、节奏、惊喜 │
│ • 结构化输出:Zod Schema 确保数据格式 │
│ │
│ 3. 8 个专业工具全覆盖 │
│ 天气 + 火车票 + 酒店 + 景点 + 餐厅 + 路线 + 日期 + POI │
│ │
│ 4. 多工具并行提升效率 │
│ Info Collector 同时调用 6-7 个工具,减少等待时间 │
│ │
│ 5. HITL (Human-in-the-Loop) │
│ 生成草稿 → 用户编辑 → 确认保存 │
│ │
└─────────────────────────────────────────────────────────────────────┘项目亮点
- 完整的 Multi-Agent 架构:Coordinator + 3 个 Sub-Agent 分工协作
- 行程规划专业化:Trip Planner Agent 专注于智能排程,不是简单的工具调用
- 8 个工具全覆盖:天气、交通、酒店、景点、餐厅、路线规划一应俱全
- 多工具并行:Info Collector 同时调用多个工具,大幅提升响应速度
- 流式体验:实时展示 AI 思考过程,用户体验好
- 可视化编辑:拖拽调整行程,支持跨天移动活动
- 优雅降级:Redis 不可用时自动使用内存缓存
- 行程修改支持:Trip Adjuster 处理用户的修改请求
扩展方向
- 接入更多数据源(机票、景点门票、餐厅预订)
- 添加地图可视化(展示行程路线、景点位置)
- 支持多人协作编辑行程
- 添加行程分享和导出功能(PDF、日历)
- 接入支付系统实现在线预订
- 添加智能推荐(根据历史偏好)
- 支持多语言(国际化行程规划)