☰
AI智能体重构旅行规划:Prompt分层+FastAPI实时票务查询实战
2026/9/28 21:09:24 网站建设 项目流程

1. 为什么我要用智能体重构旅行规划这件事

做旅行规划这件事,我前前后后折腾了快三年。最早是纯手工,Excel 拉表格、浏览器开二十个标签页比价、备忘录里记航班号,一趟出行规划下来少说四五个小时,遇上旺季票价波动,前面查的全白费。后来用脚本爬数据,能省点力气,但脚本脆得很,页面结构一变就崩,维护成本比手工还高。再后来大模型起来了,我第一反应就是拿它来干这个活,但一开始也只是把它当个"高级搜索框"用,问一句答一句,体验很割裂。

真正的转折点是我意识到:旅行规划本质上不是一个问答任务,而是一个多步骤、有状态、需要外部实时数据支撑的工作流。你得先理解用户模糊的需求("我想找个暖和的地方躺几天"),再把它翻译成结构化的约束(预算、天数、出发地、偏好),然后去查实时航班和票价,比对酒店,最后组装成一份能直接执行的行程。这里面每一步的输入输出格式都不一样,中间还会因为票价变化、余票不足而反复调整。用一个大 Prompt 硬塞给模型,它要么漏步骤,要么编数据,要么在长上下文里把前面的约束忘了。

所以我决定用AI 智能体(Agent)的思路来重构整个流程,核心是两件事:一是把规划逻辑拆成可组合的 Prompt 工程模块,让每个环节职责单一、可测试;二是用FastAPI搭一个实时票务查询服务,让智能体能真正拿到"此刻"的航班和价格数据,而不是靠模型记忆里的过期信息瞎编。这套东西跑通之后,我规划一趟国内往返的行程,从输入需求到拿到可执行方案,稳定在 40 秒以内,而且票价是真实的、可下单的。

这篇文章我会把这套工作流从设计思路到代码落地完整拆一遍,包括 Prompt 怎么分层、FastAPI 服务怎么设计接口、智能体怎么编排工具调用、以及我在实测中踩过的那些坑。适合已经会用大模型 API、想往智能体方向深入的后端或全栈开发者,也适合做旅游类产品的同学参考架构。不需要你是算法专家,但得能看懂 Python 和基本的 HTTP 接口。

2. 整体架构设计与技术选型思路

2.1 为什么是"智能体 + 工具服务"而不是"一个大模型"

先说清楚一个概念上的分界。很多人把"用大模型做应用"和"做智能体"混为一谈,其实差别很大。前者是你把问题整理好、喂给模型、拿回答案,模型是被动的;后者是模型自己决定"我现在该干什么",它会主动选择调用哪个工具、传什么参数、拿到结果后下一步做什么。旅行规划这个场景,天然适合后者,因为它的决策链条长且依赖外部状态。

我举个具体的例子你就明白了。用户说"下个月想去成都吃火锅,三天,预算三千"。如果是一个大 Prompt,模型会直接给你一份行程,但里面的航班时间、票价全是它编的,因为它没有实时数据。而智能体的做法是:它先解析出"出发地未知、目的地成都、时长三天、预算三千、主题美食",发现出发地缺失,于是主动追问;拿到出发地后,它调用票务查询工具查下个月的低价航班,拿到真实结果后再排行程。这个"发现缺失、主动追问、调用工具、整合结果"的循环,就是智能体的核心价值。

我选这个架构的另一个原因是可维护性。旅行规划涉及航司、酒店、天气、签证、汇率等一堆外部依赖,如果全塞进一个 Prompt,任何一处数据源变化都要重写整个 Prompt。拆成智能体加工具服务之后,每个工具是独立的 API,Prompt 只管决策逻辑,数据源换了只改对应的服务,互不影响。

2.2 FastAPI 在这个架构里扮演什么角色

FastAPI 是我给智能体准备的"手和脚"。智能体本身只会思考和决策,它要拿到真实数据,必须通过工具调用,而工具的背后就是 FastAPI 提供的 HTTP 接口。我选 FastAPI 而不是 Flask 或 Django,主要看中三点。

第一是异步原生支持。票务查询往往要并发请求多个数据源(比如同时查几家航司的接口),FastAPI 基于 Starlette 的 async 能力让并发写起来很自然,不用自己折腾线程池。第二是自动生成接口文档。智能体在决定调用哪个工具时,需要知道工具的参数格式,FastAPI 自动生成的 OpenAPI schema 可以直接喂给模型做 function calling 的定义,省了我手写工具描述的工作。第三是Pydantic 的数据校验。票务查询的入参出参格式必须严格,Pydantic 模型能在入口就把脏数据挡掉,避免模型传了奇怪的参数导致下游报错。

整个架构的数据流是这样的:用户在前端输入需求,请求打到智能体编排层,编排层调用大模型做意图理解和任务分解,模型决定调用哪个工具,编排层通过 HTTP 请求 FastAPI 服务,FastAPI 去查真实数据源并返回结构化结果,编排层把结果回灌给模型,模型继续下一步决策,直到生成最终行程。

2.3 工作流的分层设计

我把整个工作流拆成了四层,每层职责清晰,层与层之间通过明确定义的数据结构通信。

层级职责关键技术输出物
意图理解层解析用户自然语言,提取结构化约束Prompt 工程 + JSON Schema结构化需求对象
决策编排层决定调用哪些工具、处理缺失信息智能体循环 + function calling工具调用序列
工具服务层提供实时票务、酒店等数据FastAPI + 异步请求结构化数据
行程生成层整合所有数据生成可执行行程Prompt 工程 + 模板Markdown 行程单

这么分层的好处是,每一层都可以单独测试和替换。比如我想换一个更便宜的大模型做意图理解,只改第一层的 Prompt 和模型配置,其他层完全不动。我想加一个新的数据源(比如高铁票),只在工具服务层加一个接口,决策编排层注册一下就行。

3. Prompt 工程的分层拆解与实操要点

3.1 意图理解 Prompt 怎么写才不跑偏

意图理解是整个工作流的入口,这一步错了后面全错。我一开始的写法是让模型"提取用户需求中的所有信息",结果它经常自作主张补全缺失字段,比如用户没说出发地,它默认成北京,导致后面查出来的航班全是错的。后来我改成强制模型区分"已知"和"未知",明确要求缺失字段必须返回 null,并且列出需要追问的问题。

我的意图理解 Prompt 核心结构是这样的:先给模型一个严格的 JSON Schema 定义,规定输出必须包含哪些字段、每个字段的类型和取值范围;然后给几个 few-shot 示例,覆盖"信息完整"和"信息缺失"两种情况;最后加一条硬约束,禁止模型编造用户没提到的信息。实测下来,加了 few-shot 之后字段提取的准确率从大概七成提到了九成以上。

这里有个细节值得说:日期处理是意图理解里最容易翻车的部分。用户说"下个月"、"五一前后"、"这周末",模型需要结合当前日期换算成具体日期。我的做法是在 Prompt 里注入当前日期,并明确要求所有相对时间必须换算成 YYYY-MM-DD 格式,无法确定的返回 null 并追问。这个改动之后,日期相关的错误基本消失了。

3.2 决策编排 Prompt 的设计逻辑

决策编排层是智能体的"大脑",它要决定在当前状态下下一步该做什么。这里的 Prompt 设计核心是把可用工具和当前状态清晰地告诉模型,让它做选择题而不是填空题。

我的做法是把所有可用工具的函数签名、参数说明、返回格式整理成一段结构化文本,放在 system prompt 里。然后在每轮对话中,把已经收集到的信息、已经调用过的工具、工具返回的结果都作为上下文传进去。模型的任务就是判断:信息够不够?不够的话该调用哪个工具?够了的话是不是可以生成行程了?

这里我踩过一个坑:工具描述写得太模糊,模型会乱调用。比如我一开始把票务查询工具描述成"查询航班信息",结果模型在用户还没确定日期的时候就调用了,传了个空日期进去。后来我把描述改成"查询指定出发地、目的地、出发日期的航班列表和价格,缺少任一参数时不要调用",模型的行为就规矩多了。工具描述本质上也是 Prompt 工程的一部分,得当成产品文案来打磨。

3.3 行程生成 Prompt 的模板化技巧

行程生成是最后一层,目标是把前面收集的所有数据组装成一份人类可读的行程单。这一层的 Prompt 相对简单,但有个关键技巧:用模板约束输出结构。我会在 Prompt 里给出一个 Markdown 模板,规定行程单必须包含概览、每日安排、交通详情、预算明细、注意事项几个部分,模型只需要往模板里填内容。

这么做的好处是输出格式稳定,前端可以直接解析渲染。如果不给模板,模型每次生成的格式都不一样,有时候用表格有时候用列表,前端处理起来很痛苦。另外我要求模型在生成行程时必须引用工具返回的真实数据,比如航班号、起降时间、价格都要来自票务查询的结果,禁止自己编。为了强化这一点,我会在 Prompt 里明确说"以下数据来自实时查询,请直接使用,不要修改"。

3.4 Prompt 版本管理与测试

Prompt 是要迭代的,我强烈建议从一开始就做版本管理。我的做法是把每个 Prompt 存成独立的文本文件,用 Git 管理,每次修改都写清楚改了什么、为什么改。同时我建了一个小型的测试集,包含二十来个典型的用户输入,每次改完 Prompt 就跑一遍,看输出是否符合预期。

这个测试集帮我避免了好几次"改好一个场景、弄坏三个场景"的悲剧。比如我有次为了提升日期解析准确率,在 Prompt 里加了一堆日期格式的说明,结果模型开始把"三天"这种时长也当成日期处理。跑测试集的时候立刻发现了,赶紧回滚调整。没有测试集的话,这种回归问题可能要等线上用户反馈才发现。

4. FastAPI 实时票务查询服务的落地实现

4.1 项目目录结构怎么组织

FastAPI 项目的目录结构我试过好几种,最后稳定在这套组织方式上,兼顾了清晰和可扩展。

ticket-service/ ├── app/ │ ├── main.py # 应用入口,注册路由 │ ├── config.py # 配置管理,读环境变量 │ ├── models/ │ │ ├── request.py # 请求体 Pydantic 模型 │ │ └── response.py # 响应体 Pydantic 模型 │ ├── routers/ │ │ ├── flight.py # 航班查询路由 │ │ └── hotel.py # 酒店查询路由 │ ├── services/ │ │ ├── flight_service.py # 航班查询业务逻辑 │ │ └── cache.py # 缓存逻辑 │ └── clients/ │ └── data_source.py # 外部数据源客户端 ├── tests/ ├── requirements.txt └── .env

这么分的原因很简单:路由层只管 HTTP 协议相关的事,业务逻辑放 service,外部依赖放 client。这样我想换数据源,只改 client 层;想加缓存,只改 service 层;路由层几乎不用动。很多新手把所有逻辑堆在路由函数里,项目一大就变成几百行的巨型函数,改起来要命。

4.2 票务查询接口的设计与参数校验

票务查询接口是整个服务的核心,我设计成 POST 而不是 GET,因为查询参数比较多,而且未来可能扩展成批量查询。请求体用 Pydantic 模型定义,把校验规则写死在模型里。

from pydantic import BaseModel, Field, field_validator from datetime import date class FlightQueryRequest(BaseModel): origin: str = Field(..., min_length=2, max_length=3, description="出发地城市代码") destination: str = Field(..., min_length=2, max_length=3, description="目的地城市代码") depart_date: date = Field(..., description="出发日期") return_date: date | None = Field(None, description="返程日期,单程为空") adults: int = Field(1, ge=1, le=9, description="成人数量") max_price: float | None = Field(None, gt=0, description="最高可接受价格") @field_validator("return_date") @classmethod def check_return_after_depart(cls, v, info): if v and "depart_date" in info.data and v < info.data["depart_date"]: raise ValueError("返程日期不能早于出发日期") return v

这里有几个设计考量。城市代码限制长度是为了防止模型传进来一长串自然语言,虽然模型一般不会这么干,但校验一下更保险。返程日期用可选字段,单程和往返共用一个接口,减少接口数量。自定义校验器检查日期顺序,这个逻辑放在模型层比放在业务层好,因为校验失败 FastAPI 会自动返回 422 错误,模型能直接看到错误信息并修正参数。

4.3 异步并发查询多个数据源

真实场景下,一个航班查询往往要问好几个数据源,串行查太慢。我用 asyncio.gather 做并发,把多个数据源的查询同时发出去,谁先回来先处理。

import asyncio import httpx async def query_all_sources(req: FlightQueryRequest): async with httpx.AsyncClient(timeout=8.0) as client: tasks = [ query_source_a(client, req), query_source_b(client, req), query_source_c(client, req), ] results = await asyncio.gather(*tasks, return_exceptions=True) flights = [] for r in results: if isinstance(r, Exception): continue # 单个数据源失败不影响整体 flights.extend(r) return sorted(flights, key=lambda x: x["price"])

这段代码有两个关键点。return_exceptions=True让某个数据源超时或报错时不会拖垮整个请求,其他数据源的结果照常返回。超时设成 8 秒是我实测下来的平衡点,太短会漏掉慢但有效的数据源,太长会让用户等得不耐烦。另外结果统一按价格排序,方便模型直接取最便宜的选项。

4.4 缓存策略与限流保护

票务数据变化快,但也不是每秒都变。我加了一层短时缓存,同一个查询条件在 5 分钟内直接返回缓存结果,减少对上游数据源的压力。缓存用内存字典实现,简单够用,如果要多实例部署再换 Redis。

import time from typing import Any _cache: dict[str, tuple[float, Any]] = {} CACHE_TTL = 300 # 5分钟 def get_cached(key: str): if key in _cache: ts, value = _cache[key] if time.time() - ts < CACHE_TTL: return value del _cache[key] return None def set_cache(key: str, value: Any): _cache[key] = (time.time(), value)

限流这块我用的是简单的令牌桶,每个 IP 每分钟最多 30 次查询。为什么要限流?因为智能体在调试阶段可能会疯狂调用接口,没有限流的话上游数据源可能把你封了。限流阈值我设得比较宽松,正常使用完全够,异常调用能挡住。

注意:缓存 key 一定要包含所有影响结果的参数,我一开始漏了成人数量,导致两个人查出来的价格和一个人一样,闹了笑话。后来我把请求体的关键字段序列化成 key,确保不同查询不会串。

5. 智能体编排与工具调用的完整流程

5.1 智能体主循环的实现

智能体的核心是一个循环:把当前状态发给模型,模型返回要么是工具调用请求,要么是最终答案,如果是工具调用就执行工具、把结果加回状态、继续循环,直到模型给出最终答案或达到最大轮数。

async def run_agent(user_input: str, max_turns: int = 8): messages = [ {"role": "system", "content": SYSTEM_PROMPT}, {"role": "user", "content": user_input}, ] for turn in range(max_turns): response = await call_llm(messages, tools=TOOL_DEFINITIONS) if response.tool_calls: for call in response.tool_calls: result = await execute_tool(call.name, call.arguments) messages.append({"role": "assistant", "tool_calls": [call]}) messages.append({"role": "tool", "content": result}) else: return response.content return "抱歉,规划过程超出预期步数,请简化需求后重试"

max_turns 设成 8是我反复调出来的。设太小,复杂需求(比如多城市联程)规划不完;设太大,万一模型陷入死循环会浪费大量 token。8 轮基本能覆盖绝大多数场景,超了就说明需求太复杂或者模型跑偏了,直接返回提示让用户简化。

5.2 工具定义与 function calling 对接

工具定义要跟 FastAPI 的接口严格对应,我写了个小脚本从 OpenAPI schema 自动生成工具定义,避免手写不一致。工具定义的核心是 name、description、parameters 三部分,其中 description 最重要,模型就是靠它判断什么时候该用这个工具。

TOOL_DEFINITIONS = [ { "type": "function", "function": { "name": "query_flights", "description": "查询指定出发地、目的地、出发日期的航班列表和价格。缺少任一必填参数时不要调用,应先向用户追问。", "parameters": { "type": "object", "properties": { "origin": {"type": "string", "description": "出发城市三字码,如 PEK"}, "destination": {"type": "string", "description": "目的地城市三字码,如 CTU"}, "depart_date": {"type": "string", "description": "出发日期,格式 YYYY-MM-DD"}, "return_date": {"type": "string", "description": "返程日期,单程不传"}, }, "required": ["origin", "destination", "depart_date"], }, }, }, ]

5.3 多轮追问与状态管理

智能体最实用的能力是主动追问。当用户信息不全时,它不应该瞎猜,而应该问清楚。我在 system prompt 里明确要求:必填参数缺失时必须追问,一次最多问两个问题,避免把用户问烦。

状态管理我用的是消息列表累积的方式,每一轮的工具调用和结果都追加到 messages 里,模型能看到完整的历史。这里要注意上下文长度控制,工具返回的航班列表可能很长,我会在返回给模型之前做一次精简,只保留航班号、时间、价格、航司这几个关键字段,把冗余信息砍掉,既省 token 又让模型更容易抓重点。

5.4 异常处理与降级方案

智能体跑起来之后,异常处理是绕不开的。我遇到过的异常主要有三类:模型返回格式错误、工具调用失败、超时。针对每一类我都做了处理。

模型返回格式错误时,我会把错误信息作为一条 system 消息塞回去,让模型重新生成,最多重试两次。工具调用失败时,我把失败原因返回给模型,让它决定是换个参数重试还是告诉用户暂时查不到。超时的话,如果已经拿到了部分数据,就让模型基于部分数据生成行程并标注哪些信息未获取到。

实操心得:给模型返回工具错误信息时,一定要用自然语言描述清楚,比如"查询失败:出发地代码 PEK 无效,请使用标准三字码",而不是直接抛 Python 异常堆栈。模型看不懂堆栈,但看得懂自然语言,能据此修正参数。

6. 实测中的常见问题与排查技巧

6.1 模型编造数据怎么破

这是最常见也最危险的问题。模型在没有调用工具的情况下,凭记忆编出航班号和价格,用户拿去下单发现根本不存在。我的解决办法有三层:第一层是在 system prompt 里反复强调"所有航班数据必须来自工具调用,禁止编造";第二层是在行程生成时做校验,检查行程里的航班号是否出现在工具返回结果中,不在就标记为可疑;第三层是在最终输出里明确标注数据来源和查询时间,让用户知道这是实时数据还是模型推测。

实测下来,加了第二层校验之后,编造数据的情况基本杜绝了。校验逻辑很简单,就是把工具返回的所有航班号收集成一个集合,生成行程后逐个比对,发现不在集合里的就触发重新生成。

6.2 工具调用参数错误的排查

模型传错参数是家常便饭,常见的有日期格式不对、城市代码用了中文、成人数量传成字符串。排查这类问题的关键是把错误信息完整地反馈给模型。我一开始只返回"参数错误",模型不知道该改什么,反复犯同样的错。后来我把 Pydantic 的校验错误信息原样返回,模型看到"depart_date 必须是 YYYY-MM-DD 格式"就知道怎么改了。

下面这张表是我整理的常见参数错误和对应处理方式,可以直接抄。

错误类型典型表现处理方式
日期格式错误传"下个月"而非具体日期返回格式要求,让模型重新换算
城市代码错误传"北京"而非"PEK"返回三字码要求,附常见城市对照
数值类型错误成人数量传"2"字符串Pydantic 自动转换,失败则报错
必填参数缺失没传出发日期返回缺失字段,让模型追问用户
日期逻辑错误返程早于出发返回校验错误,让模型修正

6.3 响应太慢的优化思路

智能体规划一趟行程涉及多轮模型调用和工具调用,响应慢是必然的。我实测下来,不做优化的话一趟要一分半,优化后压到 40 秒左右。主要的优化手段有三个。

并行化工具调用。如果模型在一轮里请求了多个互不依赖的工具(比如同时查航班和酒店),我会并发执行,而不是串行。这一项就能省下十几秒。精简上下文。工具返回的数据只保留必要字段,历史消息超过一定长度就做摘要压缩,减少模型处理时间。流式输出。最终行程生成时用流式返回,用户能边看边等,感知上的等待时间大幅缩短。

6.4 成本控制的实战经验

智能体跑起来 token 消耗很快,尤其是多轮循环加上工具返回的长文本。我做了几件事来控制成本。意图理解用便宜的小模型,这个任务简单,小模型完全够用,只有决策编排和行程生成才用大模型。工具返回结果做截断,航班列表最多返回 10 条,按价格排序取前 10,避免把上百条结果全塞给模型。缓存重复查询,同一个用户短时间内重复问类似问题,直接命中缓存。

我算过一笔账,优化前规划一趟行程大概消耗 15000 token,优化后降到 5000 左右,成本降了三分之二,而用户体验几乎没受影响。

7. 这套工作流还能怎么扩展

跑通基础版本之后,我陆续加了一些扩展,这里挑几个实用的说说。

多城市联程规划。用户说"北京到成都再到昆明最后回北京",这涉及三段航班,智能体需要分别查询再拼接。我的做法是让意图理解层把这种需求拆成多个航段,决策层对每个航段分别调用工具,最后统一组装。这里要注意航段之间的时间衔接,我加了一个校验,确保下一段的出发时间晚于上一段的到达时间加两小时。

预算约束下的自动比价。用户给了预算上限,智能体在拿到航班列表后会自动筛选出预算内的选项,如果全都超预算,它会提示用户并给出最接近的选项。这个逻辑我放在行程生成层,用简单的数值比较实现,不需要模型参与,更快更准。

行程的二次修改。用户拿到行程后说"第二天太赶了,能不能轻松点",智能体需要理解这是对已有行程的修改请求,而不是全新规划。我的做法是在状态里保留上一版行程,修改请求进来时把旧行程作为上下文一起传给模型,让它做增量修改而不是重新生成。

接入更多数据源。除了航班,我还接了天气和酒店。天气接口用来在行程里提示"第三天有雨,建议室内活动",酒店接口用来补充住宿推荐。每接一个新数据源,就是在 FastAPI 里加一个路由,在工具定义里加一条,工作量很小。

这套东西我陆陆续续迭代了小半年,现在自己出门基本都靠它规划。最大的体会是,智能体的价值不在于模型多聪明,而在于你把工作流拆得多清楚。Prompt 分层、工具解耦、状态管理,这些工程上的功夫才是决定体验的关键。模型能力会一直进步,但好的架构设计能让你在换模型的时候几乎零成本迁移。如果你也在做类似的东西,建议先把意图理解和工具服务这两层做扎实,上层编排反而没那么难。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询