ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

AI旅游Agent四层架构实战:从对话理解到支付闭环的工程落地

AI旅游Agent四层架构实战:从对话理解到支付闭环的工程落地 1. 一个旅游Agent到底要拆成几块才跑得通去年年底接了个私活帮一家做定制游的工作室搭一套AI旅游助手。需求听起来不复杂用户用自然语言说五一想去云南带爸妈预算一万五别太累系统得能聊、能查、能算、能下单。真动手才发现这东西表面是个聊天框底下是一整条从对话理解到支付闭环的链路任何一环断了用户体验就是这AI真傻。我把这套东西完整跑通之后复盘了一遍发现它其实可以拆成四层对话层负责把用户模糊的旅行意图变成结构化需求编排层负责决定下一步该调哪个工具工具层通过MCP协议对接航班、酒店、门票、天气这些外部能力交易层处理订单生成和支付。这四层里最容易被低估的是编排层和交易层——前者决定了Agent是聪明还是智障后者决定了这东西能不能真正赚钱。这篇就把我踩过的坑、选型的理由、以及每一层的具体实现思路摊开讲。适合正在做AI应用落地、尤其是涉及多工具调用和支付闭环的开发者看。如果你只是想做个demo聊聊天那看到第二层就够了但如果你想让用户真的掏钱下单第四层才是真正的深水区。先说一个反直觉的结论旅游Agent的难点从来不在大模型本身。现在随便一个主流模型都能把带爸妈去云南别太累理解成需要慢节奏、少爬山、住宿要电梯房。真正难的是理解完之后你怎么把这个意图可靠地映射到几十个外部接口上并且在用户改主意的时候不把整个订单搞崩。这才是工程问题不是模型问题。2. 对话层把我想去云南翻译成机器能算的东西2.1 为什么不能直接让模型输出JSON就完事很多人做对话层的第一反应是写个prompt让模型把用户的话抽成JSON字段就是目的地、人数、预算、时间。我一开始也这么干结果上线第一天就翻车。用户说下个月找个暖和的地方待一周模型抽出来{destination: null, duration: 7天}目的地是空的后面的工具调用直接卡死。问题出在旅游需求是渐进明确的用户不会一次性把所有信息给全。你逼着模型一次抽完它要么瞎猜要么留空。正确的做法是把对话层设计成**槽位填充slot filling**的状态机而不是一次性抽取。我最终的方案是维护一个TravelIntent对象里面有一组槽位目的地、出发地、时间范围、人数构成、预算区间、节奏偏好、特殊约束老人/小孩/忌口。每轮对话只更新变化的槽位缺哪个就主动问哪个。这个逻辑用代码控制模型只负责从这句话里提取哪些槽位有更新。class TravelIntent: def __init__(self): self.slots { destination: None, origin: None, date_range: None, party: None, # {adults: 2, elders: 2, kids: 0} budget: None, # {min: 10000, max: 15000, currency: CNY} pace: None, # relaxed / packed constraints: [] # [no_hiking, elevator_hotel] } self.confirmed set() # 已确认的槽位 def missing_required(self): required [destination, date_range, party] return [k for k in required if self.slots[k] is None]2.2 槽位提取的prompt怎么写才稳提取槽位的prompt我改了七八版最后稳定下来的结构是这样的给模型明确的槽位定义、给几个边界案例、要求它只输出有变化的字段。关键是不要让它输出完整对象否则它会把上一轮已经确认的值又改一遍造成状态漂移。提示槽位提取的prompt里一定要加一句如果用户这句话没有提供某个槽位的信息不要输出该字段也不要猜测。我加这句之前模型特别爱自作主张用户说预算可以再加点它直接把预算改成某个具体数字纯属幻觉。还有一个细节人数构成要单独拆开。用户说我们一家四口模型可能抽成{adults: 4}但实际可能是两大两小。我后来强制要求party字段必须区分adults、elders、kids三类因为这三类直接决定了后续的酒店房型、门票优惠、行程节奏。这个信息在对话层不拆清楚到工具层就是灾难。2.3 多轮对话里的指代消解旅游对话里指代特别多。那边天气怎么样那个酒店能退吗换成前面说的那个。如果对话层不做指代消解工具层根本不知道那边是哪儿。我的做法是在每轮对话后维护一个context_stack记录最近提到的实体目的地、酒店名、日期。当模型提取槽位时把最近三轮的对话和当前context一起喂进去让它自己判断指代。实测下来把context显式传给模型比让它从完整历史里自己找要准得多因为完整历史太长模型注意力会散。这里有个经验context_stack不要超过5个实体多了反而干扰。我试过存10个模型开始把不相关的实体往当前意图上套准确率反而下降。3. 编排层Agent的大脑其实是个路由器3.1 为什么我放弃了纯ReAct一开始我用的是经典的ReAct模式——让模型自己思考该调什么工具、调完看结果再决定下一步。跑简单场景没问题但旅游场景一复杂就崩。比如用户说帮我看看五一去成都的机票和酒店如果总价超一万二就换成高铁ReAct会陷入反复横跳查机票、查酒店、算总价、发现超了、又回去查高铁、再算……有时候能绕五六轮token烧得飞快还容易在中途丢失原始约束。后来我改成了**规划-执行两段式**先让模型基于当前意图生成一个执行计划DAG再由代码按计划调度工具。计划里明确每一步的输入输出和依赖关系模型只在计划生成和结果汇总两个环节介入中间的调度完全由代码控制。# 计划的结构大概长这样 plan { steps: [ {id: s1, tool: search_flights, args: {...}, depends_on: []}, {id: s2, tool: search_hotels, args: {...}, depends_on: []}, {id: s3, tool: calc_total, args: {flight: $s1, hotel: $s2}, depends_on: [s1, s2]}, {id: s4, tool: search_trains, args: {...}, depends_on: [s3], condition: $s3.total 12000} ] }这样做的好处是可预测、可调试、可缓存。s1和s2没有依赖可以并行调用s4带条件只有总价超标才执行。整个流程是确定的出问题能精确定位到哪一步。3.2 工具描述怎么写模型才选得对编排层能不能选对工具八成取决于工具描述的质量。我见过太多人把工具描述写成查询航班信息然后抱怨模型老是选错。工具描述要包含四要素做什么、什么时候用、输入长什么样、输出长什么样。举个例子同样是查交通我有三个工具search_flights、search_trains、search_driving。如果描述都写成查询交通方式模型必然乱选。我的写法是search_flights适用于跨省或跨国、时间紧、预算充足的场景。输入需要出发城市、到达城市、日期。输出含航班号、起降时间、价格、是否直飞。search_trains适用于中短途、预算敏感、或用户明确提到高铁火车。输入同上。输出含车次、历时、席别、价格。search_driving仅当用户明确说自驾或目的地无公共交通时使用。这样写之后模型选错的概率大幅下降。关键是把什么时候用写清楚这是模型做路由决策时最需要的信息。3.3 并行调用和超时控制旅游场景里查机票、查酒店、查天气这三个操作互不依赖串行调用会让用户等十几秒。我用了asyncio.gather做并行整体响应时间从12秒压到4秒左右。但并行带来一个新问题某个工具超时了怎么办。我的策略是分级降级——核心工具比如目的地确认后的酒店查询超时就重试一次非核心工具比如天气超时就直接跳过在最终结果里标注天气信息暂不可用。绝对不能让一个天气接口把整个行程规划卡死。注意并行调用时一定要给每个工具设独立的超时时间不要用统一的全局超时。我一开始用全局10秒结果一个慢接口拖垮了所有快接口的返回。4. 工具层MCP到底解决了什么真问题4.1 没有MCP之前我是怎么接工具的在MCP这个概念火起来之前我接外部工具的方式很原始每个工具写一个适配器类把HTTP接口包一层然后在编排层里硬编码调用。问题很明显——每接一个新工具就要改编排层的代码加一个分支。接了十几个工具之后编排层变成了一坨意大利面谁都不敢动。MCPModel Context Protocol的价值就在这儿它把工具抽象成一个标准协议工具提供方按协议暴露能力Agent按协议发现和调用。编排层不再关心具体工具怎么实现只关心协议层面的调用。这就像USB接口统一了外设连接你不用为每个鼠标写一个驱动。4.2 MCP Server的三种能力Tools、Resources、PromptsMCP Server对外暴露三类东西很多人只用了Tools其实另外两类在旅游场景里也很有用。Tools是主动调用的能力比如search_flights、book_hotel。这是最常用的。Resources是被动读取的数据比如当前用户的会员等级目的地的实时汇率。它和Tools的区别是Tools是我要做一件事Resources是我要读一份数据。旅游场景里用户的偏好档案、历史订单都适合做成Resource。Prompts是预置的提示模板比如生成一份带老人的慢节奏行程这个模板可以固化下来用户触发时直接调用不用每次重新组织语言。我实际项目里Tools用了最多Resources用来存用户画像Prompts基本没用上——因为旅游需求的个性化太强预置模板反而限制发挥。但如果你的场景是标准化的比如只做某条固定线路Prompts能省不少事。4.3 自己写MCP Server的几个坑我为了对接几个没有现成MCP封装的供应商接口自己写了两个MCP Server。踩的坑记录一下第一个坑是工具粒度的划分。我一开始把查酒店和订酒店做成一个工具参数里带个action字段。结果模型经常搞混明明只是想查却传了actionbook。后来拆成两个独立工具各管各的问题消失。工具要按意图拆不要按资源拆。第二个坑是错误信息的格式。MCP协议里工具返回的错误如果只是一句调用失败模型完全不知道怎么处理。我后来统一了错误结构包含错误码、人类可读的描述、以及建议的下一步动作。比如库存不足时返回{code: NO_AVAILABILITY, message: 该日期已满房, suggestion: 尝试相邻日期或同区域其他酒店}模型拿到这个就能自动调整策略。第三个坑是鉴权。MCP Server调用外部接口需要凭证这些凭证绝对不能暴露给模型。我的做法是凭证存在Server端的环境变量里模型只能通过工具名调用看不到任何密钥。这一点在涉及支付的场景里尤其重要。4.4 工具返回结果怎么喂回给模型工具返回的原始数据往往很啰嗦一个航班查询可能返回几十个字段。直接全塞给模型token爆炸不说还容易干扰判断。我的做法是在MCP Server层面就做结果裁剪只返回模型决策需要的字段其余的存在服务端等真正下单时再取。比如航班查询模型只需要知道航班号、时间、价格、是否直飞。至于机型、餐食、行李额这些等用户选定后再展示。这样单次工具调用的返回能控制在200 token以内整个规划流程的token消耗降了一大半。5. 交易层从帮你查到帮你付的惊险一跃5.1 为什么支付是旅游Agent的分水岭前面三层做得再好用户最后不掏钱这Agent就是个玩具。而支付这一层是纯工程问题跟AI一点关系没有但恰恰是最容易出事的地方。旅游订单的支付有几个特点金额大、涉及多方分账、退款规则复杂、时效性强。一个机票订单可能涉及航司、平台、代理三方分账一个酒店订单可能提前三天免费退、三天内收50%。这些规则如果不在下单前就跟用户讲清楚后面全是纠纷。我的原则是Agent可以帮用户决策但支付确认必须由用户显式完成。绝对不能让Agent自动扣款哪怕用户说了你看着办。这不是技术问题是信任问题。5.2 订单状态机怎么设计旅游订单的状态比普通电商复杂得多。我设计的状态机大概是这样状态含义可执行动作draft草稿用户还在调整修改、删除、提交pending_payment待支付库存已锁定支付、取消paid已支付等待确认申请退款confirmed供应商已确认申请退款、改期completed行程结束评价refunding退款中无refunded已退款无cancelled已取消无关键点是pending_payment状态要有超时。库存锁定一般只有15到30分钟超时未支付要自动释放。我一开始没做这个超时结果用户锁了一堆库存不付款供应商那边天天投诉。5.3 支付渠道的对接思路支付渠道的对接核心是统一下单接口 异步回调 主动查询三件套。不管对接哪家逻辑都差不多你的服务端生成订单号调用渠道的下单接口拿到支付凭证二维码或跳转链接用户完成支付后渠道回调你的服务端你更新订单状态。这里有几个实操要点第一回调必须做幂等。渠道可能重复回调你的处理逻辑必须保证同一笔订单只被处理一次。我用订单号加唯一索引来兜底重复回调直接返回成功但不重复处理。第二不能只依赖回调。网络抖动、渠道故障都可能导致回调丢失。我加了一个定时任务每隔几分钟主动查询pending状态的订单跟渠道对账。这个兜底机制救过我好几次。第三金额校验。回调里带的金额必须和你下单时的金额一致不一致直接拒绝。这是防篡改的基本操作但很多人会漏。def handle_payment_callback(order_id, paid_amount, channel_txn_id): order db.get_order(order_id) if order is None: return {code: ORDER_NOT_FOUND} if order.status ! pending_payment: # 幂等已处理过直接返回成功 return {code: SUCCESS} if abs(order.amount - paid_amount) 0.01: log.warning(f金额不一致 order{order.amount} paid{paid_amount}) return {code: AMOUNT_MISMATCH} # 事务内更新订单状态 with db.transaction(): order.status paid order.channel_txn_id channel_txn_id order.paid_at now() db.save(order) return {code: SUCCESS}5.4 分账和退款的坑分账这块如果平台涉及多方比如你、供应商、地接一定要在下单时就确定好分账规则不要等支付完再算。我见过有团队支付完才发现分账比例算错只能人工补非常被动。退款更麻烦。旅游产品的退款规则往往是阶梯式的提前7天全退、3到7天退80%、3天内退50%、24小时内不退。这些规则要在下单时就从供应商那里拿到并展示给用户而不是等用户申请退款时才去查。我吃过这个亏——用户申请退款时才发现供应商的规则和展示的不一致最后平台自己贴钱。提示退款规则一定要在下单前用用户能看懂的话展示并且让用户显式确认。别藏在用户协议里出了纠纷没人看协议。6. 把四层串起来一次完整的下单流程长什么样光讲分层太抽象我把一次真实的交互流程完整走一遍你就能看清四层是怎么协作的。用户输入帮我订五一去成都的机票两个人预算五千以内要直飞。对话层收到这句话提取槽位destination成都date_range五一需要进一步确认具体日期party{adults:2}budget{max:5000}constraints[direct_flight]。发现date_range不够精确追问五一期间具体哪天出发用户回复4月30号走5月3号回。对话层更新date_range此时必填槽位齐全触发编排层。编排层生成计划调用search_flights查4月30日出发、5月3日返回的成都直飞航班筛选价格在5000以内的。这里注意预算是两人总价还是单人我在槽位定义里明确是总价避免歧义。工具层通过MCP调用航班查询工具返回三个符合条件的航班。编排层把结果汇总交给模型生成自然语言回复找到三个直飞航班最便宜的是XX航空两人往返4800符合你的预算。用户说就这个吧帮我订。编排层触发下单流程先调用lock_inventory锁定库存生成订单进入pending_payment状态然后调用支付渠道生成支付链接。交易层返回支付二维码用户扫码完成支付渠道回调订单状态更新为paid再调用供应商的确认接口最终变成confirmed。整个流程里模型只在两个地方介入理解用户意图、生成自然语言回复。中间的调度、锁定、支付全是代码控制。这样既保证了灵活性又保证了可靠性。7. 几个让我印象深刻的翻车现场7.1 时区问题差点让我赔钱有一次用户订国际航班出发地是北京目的地是洛杉矶。航班查询工具返回的到达时间是当地时间但我在展示时没标注时区用户以为是北京时间结果算错了转机时间差点误机。后来我在所有时间字段上都强制带时区标识展示时统一转成用户所在时区。这个坑在旅游场景里特别常见因为旅游天然跨时区。任何涉及时间的字段都要问自己一句这是哪个时区的时间7.2 并发下单导致超卖早期版本没做库存锁定的并发控制两个用户同时下单同一间房都显示成功结果供应商那边只有一间。后来加了分布式锁以资源ID日期为key下单前先抢锁抢不到就提示该资源正在被其他用户预订。分布式锁的粒度要细不能锁整个酒店要锁到具体房型具体日期。锁太粗会导致大量用户被误伤锁太细又起不到防超卖的作用。7.3 模型把不要理解反了用户说不要红眼航班模型在筛选时把红眼航班留下了把正常航班过滤掉了。排查发现是prompt里否定词的处理有问题。后来我在槽位里把这类约束统一转成正向表达比如不要红眼转成preferred_time[morning, afternoon]避免模型在否定逻辑上出错。否定词是LLM的老大难能转成正向表达就转别指望模型每次都理解对。8. 关于这套架构我最后想说的几句实在话这套四层架构我跑了半年多最大的体会是AI旅游Agent的竞争力不在模型在于工具层的覆盖度和交易层的可靠性。模型大家用的都差不多但你能不能查到某个小众目的地的特色民宿能不能在用户改主意时快速释放库存重新下单这些才是拉开差距的地方。MCP这个协议确实让工具接入规范了很多但也别神话它。它解决的是怎么接的问题解决不了接什么和接得稳不稳的问题。工具的质量、错误处理、超时降级这些还是得自己一点点磨。支付这块我的建议是能不自建就不自建尽量用成熟的支付渠道把精力放在业务逻辑上。自建支付通道涉及的东西太多对账、风控、合规每一样都是坑不是小团队能扛的。最后分享一个我一直在用的调试技巧把每一次完整的用户会话从输入到下单都记录下来包括每层的中间状态。出问题时回放整个链路比看日志高效得多。我专门写了个回放工具能把一次会话的槽位变化、工具调用、状态流转按时间轴展示出来定位问题基本五分钟内搞定。这套东西还在迭代最近在琢磨怎么把用户的行程偏好沉淀成长期记忆让Agent越用越懂用户。等跑通了再写一篇。
返回列表