Skip to content

LangChain 教程 36|项目实战:AI 旅游规划师

📖 本篇导读:这是 LangChain 系列教程的第 36 篇。本篇将构建一个智能旅游规划助手,支持智能对话、多工具并行、行程规划、行程编辑和保存。读完预计需要 20 分钟。

项目概述

简单来说

构建一个智能旅游规划助手,用户只需说"我想去杭州玩3天,预算5000",系统就能自动:

  • 查询目的地天气
  • 搜索机票/火车票
  • 推荐酒店住宿
  • 规划景点和餐厅
  • 生成完整的多日行程表

用户可以在线修改行程,确认后保存。

核心功能

功能描述
智能对话自然语言理解用户出行需求
多工具并行同时查询天气、交通、住宿、景点
行程规划自动编排多日行程,合理安排时间
行程编辑可视化编辑行程,拖拽调整顺序
行程保存保存历史行程,支持分享

AI 旅游规划师五大核心功能

技术亮点

┌─────────────────────────────────────────────────────────────┐
│                    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 混合模式,核心设计如下:

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)

理由:

  1. 复杂推理:行程生成需要综合考虑天气、交通、景点距离、用餐时间、体力消耗等多个因素
  2. 创造性任务:需要 LLM 的创造力来安排有趣的行程主题和节奏
  3. 长输出:生成多日行程是长文本任务,作为工具返回容易被截断
  4. 迭代优化:可能需要多轮调整,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 个工具)

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 集成

外部 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.md

6.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.ts

7.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 处理)

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;

八、行程编辑页面

HITL 行程编辑交互循环

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-AgentLangGraph StateGraphRouter + Sub-Agent 混合架构
CoordinatorwithStructuredOutput意图识别 + 参数提取 + 路由
Info CollectorcreateReactAgent8 个工具并行信息收集
Trip PlannerwithStructuredOutput结构化行程生成
Trip AdjustercreateReactAgent行程修改 + 冲突解决
工具调用@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)                                        │
│     生成草稿 → 用户编辑 → 确认保存                                   │
│                                                                     │
└─────────────────────────────────────────────────────────────────────┘

项目亮点

  1. 完整的 Multi-Agent 架构:Coordinator + 3 个 Sub-Agent 分工协作
  2. 行程规划专业化:Trip Planner Agent 专注于智能排程,不是简单的工具调用
  3. 8 个工具全覆盖:天气、交通、酒店、景点、餐厅、路线规划一应俱全
  4. 多工具并行:Info Collector 同时调用多个工具,大幅提升响应速度
  5. 流式体验:实时展示 AI 思考过程,用户体验好
  6. 可视化编辑:拖拽调整行程,支持跨天移动活动
  7. 优雅降级:Redis 不可用时自动使用内存缓存
  8. 行程修改支持:Trip Adjuster 处理用户的修改请求

扩展方向

  • 接入更多数据源(机票、景点门票、餐厅预订)
  • 添加地图可视化(展示行程路线、景点位置)
  • 支持多人协作编辑行程
  • 添加行程分享和导出功能(PDF、日历)
  • 接入支付系统实现在线预订
  • 添加智能推荐(根据历史偏好)
  • 支持多语言(国际化行程规划)

读文档、看源码、写代码,理解 AI Agent 本质 🤖