# flight-agent **Repository Path**: blueskythinking/flight-agent ## Basic Information - **Project Name**: flight-agent - **Description**: 【传统 NLU + 状态机】 用户用自然语言交互,Agent 完成意图识别、槽位填充、信息校验、订单创建、订单查询全流程 基于Java + Spring Boot + LangChain4j 的多轮对话订票 Agent。 - **Primary Language**: Java - **License**: Not specified - **Default Branch**: master - **Homepage**: None - **GVP Project**: No ## Statistics - **Stars**: 0 - **Forks**: 0 - **Created**: 2026-06-14 - **Last Updated**: 2026-06-15 ## Categories & Tags **Categories**: Uncategorized **Tags**: None ## README # Flight Booking Agent 基于 Java + Spring Boot + LangChain4j 的多轮对话订票 Agent。用户用自然语言交互,Agent 完成意图识别、槽位填充、信息校验、订单创建、订单查询全流程。 --- ## 1. 解决的问题 > 把"自然语言订票"这件事做成一个**可测试、可生产、可扩展**的工程实现。 具体要解决三类问题: ### 1.1 多轮槽位填充 用户不会一次说全所有信息。系统要在**任意输入顺序、任意切片粒度**下都能正确累积上下文: ``` 用户: 帮我订上海到东京的机票 Agent: 请告诉我出发和返程日期。 用户: 8月1日出发 Agent: 请告诉我返程日期。 用户: 8月5日回来,2个人,经济舱 Agent: 请提供 2 位乘机人的姓名/身份证/手机号。 用户: 张三 11010119900307551X 13888888888 Agent: 还需要第 2 位乘机人信息。 用户: 李四 110101199103086620 13912345678 Agent: 订单已生成 FO20260801xxxx ... ``` 经典坑点: - 同一槽位被多次部分提及,覆盖还是合并? - 多个乘机人分多轮输入,如何保证不被后来者覆盖? - 用户突然改主意("算了改成 8 月 3 号出发"),如何只更新单字段? ### 1.2 LLM 不可靠的边界控制 LLM 抽取 JSON 偶尔会写错身份证、瞎编日期、漏字段。如果把校验、状态流转、订单写库都丢给 LLM,**线上一定出事**。本项目把 LLM 严格限制在"非确定性的语言任务"上: | 由 LLM 做 | 由 Java 做 | |-----------------------------|---------------------------------------------| | 自然语言 → 结构化 JSON(抽取) | 身份证 / 手机号 / 日期校验 | | 缺失字段 → 自然语言追问(生成) | 上下文合并、状态机流转、订单写库 | 任何"出错代价高"的环节都不交给 LLM。 ### 1.3 多意图扩展性 订票只是起点。真实业务还有**查询、退票、改签、值机、退款**等。系统必须做到"加一个意图 = 加一个 Handler 实现",不修改既有任何代码。 本项目当前已落地 BOOK + QUERY 两个意图,REFUND/CHANGE 可按同一模式无缝扩展。 --- ## 2. 技术架构 ### 2.1 模块分层 ``` ┌──────────────────┐ │ ChatController │ REST 入口 └────────┬─────────┘ ▼ ┌──────────────────┐ │ ChatService │ 顶层调度(路由 + 分派 + 持久化) └────────┬─────────┘ ▼ ┌──────────────┴──────────────┐ │ IntentRouter │ 关键词 + sticky context └──────────────┬──────────────┘ ▼ ┌────────────────IntentHandler─────────────────┐ │ BookingIntentHandler │ QueryIntentHandler │ ...REFUND/CHANGE └────────┬───────────────┴──────────┬───────────┘ ▼ ▼ ┌──────────────┐ ┌──────────────────┐ │ BookingFlow │ │ OrderQueryService│ │ + Merger │ │ │ │ + Validator │ └──────┬───────────┘ │ + Formatter │ ▼ └──────┬───────┘ ┌──────────────────┐ ▼ │ FlightOrderRepo │ ┌──────────────────┐ │ (MySQL/H2) │ │ Extractor (LLM) │ └──────────────────┘ │ QuestionGen(LLM) │ └──────────────────┘ ``` > 完整 Mermaid 架构图、时序图、状态机:见 [`docs/architecture.md`](docs/architecture.md) ### 2.2 核心组件 | 组件 | 职责 | |----------------------------|--------------------------------------------------------------------| | `ChatService` | 仅负责"加载 ctx → 路由 → 分派 → 保存",不含任何业务逻辑 | | `IntentRouter` | 关键词 + sticky 上下文识别意图,不调 LLM(决策敏感、关键词命中率高) | | `IntentHandler` (接口) | 一个意图一个实现,新增意图无需改动调度层 | | `BookingFlow` | 订票流水线:抽取 → 合并 → 校验 → 追问 / 搜索 / 下单 | | `ContextMerger` | 增量合并 ctx(下文重点说) | | `BookingValidator` | 身份证/手机号/日期校验,纯 Java | | `OrderQueryService` | 按 name / idCard / phone 任一字段查订单 | | `LlmJsonClient` | 包装 LangChain4j,强约束 JSON 输出 + 失败重试 | | `SessionMemory` | 会话状态持久化,可切换 Redis / 内存(测试用) | --- ## 3. 技术实现亮点 ### 3.1 意图驱动 + sticky context 路由 意图判断不能只看当前一句话,否则用户在多轮中只输入"张三 13888888888"会被误判为订票或被丢回 UNKNOWN。 路由用三段式: 1. **显式关键词命中** → 切换意图(用户主动改流程) 2. **沿用 ctx 中进行中的意图** → 保持(多轮中间轮) 3. **弱信号兜底** 意图的"生命周期"由 handler 通过 `AgentResponse.finished` 表达,`ChatService` 据此清掉 ctx.intent 回到中性状态。**新增退票/改签时,router 和 ChatService 一行不用改**。 ### 3.2 多乘机人增量合并 ContextMerger 解决的核心问题:用户分多轮报多名乘机人时,后来者**不能覆盖**前面尚未填满的槽位。 ```java // 关键约束:仅当本轮输入"没有姓名"时,才允许填进既有不完整槽位 if (isBlank(p.getName())) { for (Passenger e : list) { if (isBlank(e.getName()) || isBlank(e.getIdCard()) || isBlank(e.getPhone())) { return e; // 补齐已有 } } } return null; // 否则视为新乘机人,append ``` 这条规则解决了我们最早遇到的真实 bug:第二个乘机人覆盖了第一个未填满的槽位。 ### 3.3 Prompt 全部外置 `resources/prompts/*.txt` 模板化,运行时由 `PromptLoader` 加载并替换 `{{var}}` 占位符。修改 prompt 不需要改 Java 代码、不需要重新打包发布。 ### 3.4 LLM 输出强约束 `LlmJsonClient` 在 prompt 中强制 `严格输出 JSON`,对返回值做: 1. 截取 ` ```json ... ``` ` 代码块或第一个 `{ ... }` 2. Jackson 解析失败 → 一次重试(带"上一轮你给的 JSON 解析失败"反馈) 3. 仍失败 → 抛出 `LlmException`,由 handler 给用户一个友好兜底 这让"LLM 偶尔返回带前后缀的解释文本"不会击穿系统。 ### 3.5 可插拔 SessionMemory `booking.memory.type=redis|in-memory` 切换后端: - 生产用 Redis,30 分钟 TTL,Jackson 序列化(已注册 JavaTimeModule) - 测试用 in-memory,无外部依赖即可跑全部测试 - `RedisConfig` 上 `@ConditionalOnProperty`,配 in-memory 时不会因为没 Redis 启动失败 ### 3.6 H2 文件模式 + Console 开发期 `jdbc:h2:file:./data/flightdb;MODE=MySQL;AUTO_SERVER=TRUE`,重启数据不丢;`/h2-console` 开放,方便直接 SQL 验证订单写入。生产换 MySQL 改一行 url 即可(已用 `MODE=MySQL` 兼容)。 ### 3.7 一体化前端自测页 `src/main/resources/static/index.html` 是 Vue 3 (CDN) 单文件控制台: - 左侧聊天,右侧实时显示 `BookingContext` JSON(看槽位填充过程) - 5 个快捷示例覆盖订票 + 查询主流程 - sessionId 存 localStorage,"重置会话"按钮一键清状态 无需 npm/构建,启动即用。 --- ## 4. 项目结构 ``` src/main/java/com/ai2/flightagent ├── controller/ REST 入口 ├── chat/ 顶层调度 + 意图路由 + Handler 接口 │ └── handler/ BookingIntentHandler / QueryIntentHandler / UnknownIntentHandler ├── booking/ 订票域:BookingFlow / ContextMerger / Formatter ├── query/ 查询域:OrderQueryService / Formatter ├── llm/ LLM 抽取器 + JSON 客户端 + Prompt 加载 ├── tool/ FlightSearchTool / OrderTool / OrderQueryTool ├── validator/ BookingValidator ├── memory/ SessionMemory + Redis/InMemory 实现 ├── state/ BookingStage 枚举 + 状态机 ├── repository/ FlightOrderRepository (JPA) ├── model/ BookingContext / Passenger / FlightOrder / AgentResponse ... └── config/ Jackson / LLM / Redis 配置 src/main/resources ├── prompts/ 外置 prompt 模板 ├── static/ Vue 前端 + favicon └── application.yml ``` --- ## 5. 快速开始 ### 启动 ```bash mvn clean package java -jar target/flight-agent.jar ``` 或 `mvn spring-boot:run`。默认 8080 端口,访问 `http://localhost:8080/` 进入自测页。 ### 配置 `application.yml` 关键项: ```yaml llm: deepseek: api-key: ${ARK_API_KEY:...} # 环境变量优先 base-url: https://ark.cn-beijing.volces.com/api/coding/v3 model: ark-code-latest booking: memory: type: in-memory # redis | in-memory ``` ### REST API ```bash # 多轮对话 curl -X POST http://localhost:8080/api/chat \ -H 'Content-Type: application/json' \ -d '{"sessionId":"s1","message":"帮我订上海到北京的机票,8月1日出发8月5日返"}' # 查会话上下文 curl http://localhost:8080/api/sessions/s1 # 重置会话 curl -X DELETE http://localhost:8080/api/sessions/s1 ``` --- ## 6. 测试 ```bash mvn test ``` 覆盖: - `BookingValidatorTest` —— 身份证/手机号/日期校验 - `ContextMergerTest` —— 单/多乘机人合并、跨轮槽位填充、增量更新 - `MissingSlotAnalyzerTest` —— 缺失字段识别 - `BookingStateMachineTest` —— 状态流转 - `FlightBookingFlowIT` —— 集成测试(mock LLM) 测试默认走 in-memory SessionMemory,**无需 Redis** 即可全部跑通。 --- ## 7. 扩展点示例:加一个"退票"意图 ```java // 1. 加枚举 enum ChatIntent { BOOK, QUERY, REFUND, UNKNOWN } // 2. 加 Handler @Component class RefundIntentHandler implements IntentHandler { public ChatIntent intent() { return ChatIntent.REFUND; } public AgentResponse handle(BookingContext ctx, String userInput) { ... } } // 3. 在 IntentRouter 加一组 REFUND_KEYWORDS ``` `ChatService` / `IntentRouter` 主体逻辑、其它 Handler、订票流程一行不用改。Spring 会自动把新 Handler 注入到 `ChatService` 的 `EnumMap`。 --- ## 8. 设计原则速记 - **意图驱动**:调度层只知道接口,不知道任何具体业务 - **LLM 边界**:只做语言任务(抽取 / 生成),不做决策、不做校验、不写库 - **状态机显式**:BookingStage 枚举 + 状态转换函数,不用"魔法字符串" - **Prompt 外置**:业务可独立调 prompt 不动代码 - **可测试性**:所有 LLM 调用走同一个 `LlmJsonClient`,集成测试用 mock 替换