1. 为什么我要拆这个AI旅游Agent的技术栈
去年下半年开始,身边做旅游、做本地生活、做SaaS的朋友几乎都在问同一个问题:能不能做一个AI旅游助手,用户说一句话就帮他规划行程、比价、下单、付款,全程不用跳来跳去?听起来像个聊天机器人加几个API的事,但真动手做过的都知道,这里面藏着的坑比想象中多得多。我自己前后参与过两个类似项目的架构设计,一个偏C端轻量助手,一个偏B端旅行社内部工具,踩过的坑从对话状态丢失到支付回调对不上账,几乎每一层都翻过车。
这篇文章我想把这类系统的完整技术栈从头到尾拆一遍,重点讲清楚四件事:前端对话层怎么设计才能既流畅又不丢上下文,MCP(Model Context Protocol,模型上下文协议)在中间扮演什么角色、为什么它比传统函数调用更适合旅游这种多工具场景,支付环节怎么接才能既合规又稳定,以及整条链路上那些文档里不会写、只有真跑过才会遇到的坑。适合正在做AI Agent的开发者、想给现有旅游产品加AI能力的团队,以及单纯想搞明白"一个能下单的AI助手到底怎么搭"的技术爱好者。读完你至少能拿到一套可以直接抄的架构骨架,以及一份避坑清单。
我先把整体链路用一句话概括:用户在对话框里说需求,前端把这句话连同会话状态打包发给后端Agent,Agent通过MCP去调用行程规划、库存查询、价格计算、下单、支付等一系列工具,最后把支付链接或二维码返回给前端完成闭环。听起来线性,但每一环都有讲究,下面逐层拆。
2. 整体架构设计与分层思路
2.1 四层架构的划分逻辑
我习惯把这类系统拆成四层:交互层、编排层、工具层、交易层。这个划分不是为了好看,而是因为每层的技术选型、迭代节奏、故障影响面完全不同,混在一起做后期维护会非常痛苦。
交互层负责对话UI、流式渲染、多轮上下文管理,迭代最快,可能一周改三次文案和交互;编排层是Agent的大脑,负责意图识别、任务拆解、工具调度,逻辑最复杂,改动风险最高;工具层是各种能力的具体实现,比如查航班、查酒店、算价格,很多是对接第三方API;交易层专门处理下单和支付,涉及资金,稳定性和合规要求最高,几乎不允许频繁改动。
这么分层的好处是:支付出问题不会影响对话体验,工具换供应商不用动Agent逻辑,前端改版不用碰后端。我见过一个项目把所有逻辑塞在一个Node服务里,结果改个支付回调要重新测整个对话流程,上线一次提心吊胆一次。
2.2 为什么中间要引入MCP而不是直接函数调用
这是很多人第一个疑问:OpenAI的函数调用(Function Calling)已经能用了,为什么还要引入MCP?我的答案很直接:当工具数量超过10个、且需要跨会话复用、跨模型迁移时,函数调用的维护成本会指数级上升。
传统函数调用是把所有工具定义塞进每次请求的prompt里,工具一多,token消耗暴涨,而且每个模型厂商的格式还不一样,换模型就要重写一遍。MCP的核心价值在于它把"工具的定义"和"工具的调用"标准化了——工具以独立的Server形式存在,Agent通过标准协议去发现和调用,工具的描述、参数schema、返回格式都是自描述的。旅游场景天然是"多工具"场景:查天气、查航班、查酒店、查门票、算预算、下单、支付,随便一数就七八个,而且未来还会加。用MCP,新增一个工具只需要部署一个新的Server,Agent侧几乎不用改代码。
提示:MCP不是银弹。如果你的Agent只有两三个固定工具,且不打算换模型,直接用函数调用更简单。MCP的收益在工具多、变化快、多模型复用的场景才明显。
2.3 技术选型对照表
下面这张表是我在两个项目里实际用过的组合,供参考:
| 层级 | 轻量方案 | 生产方案 | 选择理由 |
|---|---|---|---|
| 交互层 | 原生WebSocket + 简单DOM | React + SSE流式 + 状态机 | 生产环境需要断线重连、消息去重 |
| 编排层 | 单文件Agent循环 | LangGraph或自研状态机 | 多轮任务需要显式状态管理 |
| 工具层 | 直接HTTP调用 | MCP Server集群 | 工具解耦、独立部署、独立扩缩容 |
| 交易层 | 第三方托管页 | 自建订单系统 + 聚合支付 | 需要订单状态可控、对账清晰 |
轻量方案适合验证想法,一周能跑通demo;生产方案是我建议真正上线时用的,尤其是交易层,千万别图省事直接用第三方托管页,后期对账和对退款的处理会让你怀疑人生。
3. 前端对话层:流式、状态与上下文管理
3.1 流式渲染为什么是刚需
旅游Agent的回复往往很长——一份三天行程可能几百字,如果等全部生成完再显示,用户要盯着转圈十几秒,体验极差。流式渲染(SSE或WebSocket)能让文字像打字一样逐字出现,首字延迟从十几秒降到一两秒,感知速度提升非常明显。
实现上我推荐SSE而不是WebSocket,原因有三:SSE基于HTTP,穿透代理和网关更省心;SSE天然单向(服务端推客户端),对话场景够用;SSE断线重连是浏览器原生支持的,WebSocket要自己写心跳和重连。唯一要注意的是SSE默认不支持POST,而对话请求往往带很长的上下文,URL放不下。我的做法是先用一个POST请求创建会话并拿到session_id,再用SSE的GET请求带着session_id去订阅流,这样既绕开了长度限制,又能复用原生重连。
// 第一步:POST创建会话 const res = await fetch('/api/chat/session', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ message: userInput, history: context }) }); const { sessionId } = await res.json(); // 第二步:SSE订阅流式结果 const es = new EventSource(`/api/chat/stream?sessionId=${sessionId}`); es.onmessage = (e) => { const chunk = JSON.parse(e.data); appendToBubble(chunk.delta); // 逐字追加 if (chunk.done) es.close(); };3.2 多轮上下文到底存什么
新手最容易犯的错是把整个对话历史一股脑塞回去,几轮之后token就爆了。我的经验是分层存储:最近3轮完整保留,更早的对话做摘要压缩,同时单独维护一个"结构化槽位"(slot)记录关键信息,比如出发地、目的地、日期、人数、预算、已选中的航班和酒店。
槽位这个东西特别重要。旅游规划是个填槽过程,用户可能先说"我想去三亚",过几轮才说"两个人,下个月初",如果只靠对话历史让模型自己提取,很容易漏或者串。我一般用一个JSON对象显式维护:
{ "destination": "三亚", "origin": "上海", "date_range": ["2025-06-01", "2025-06-04"], "travelers": 2, "budget": null, "selected_flight": null, "selected_hotel": null, "stage": "collecting" }每次请求把槽位和最近对话一起发给Agent,Agent返回时也更新槽位。这样即使用户中途说"算了改成两个人",槽位一改,后续所有工具调用都基于新值,不会出现前后矛盾。
3.3 对话状态机的必要性
纯靠模型自由发挥,多轮任务很容易跑偏——用户问了个无关问题,回来之后Agent忘了刚才规划到哪了。我强烈建议在编排层加一个轻量状态机,把旅游规划拆成几个明确阶段:需求收集 → 方案生成 → 方案确认 → 下单 → 支付 → 完成。每个阶段允许的操作和工具是有限的,模型只能在当前阶段的工具集里选。
这样做的好处是可控。比如在"需求收集"阶段,Agent只能调用查询类工具,绝对不能触发下单;到了"支付"阶段,只允许调用支付相关工具。我踩过的坑就是没做阶段限制,测试时模型在用户还没确认行程的情况下直接调了下单接口,虽然没造成实际损失,但吓出一身冷汗。
4. MCP层:工具标准化与调度实战
4.1 MCP的核心概念用大白话讲
MCP你可以理解成"AI世界的USB接口"。以前每个工具都要为每个AI应用单独适配一次,就像以前每个设备都有自己专用的充电口;MCP定义了一套标准协议,工具方只要实现一次MCP Server,任何支持MCP的Agent都能直接插上用。
它主要包含三个东西:Resources(资源,只读数据,比如酒店列表)、Tools(工具,可执行动作,比如下单)、Prompts(提示模板,预置的对话模板)。旅游场景里,查库存、查价格属于Resources或Tools,下单、退款属于Tools,行程模板属于Prompts。
4.2 旅游场景的MCP Server拆分
我一般按业务域拆Server,而不是按技术类型拆。具体是这么分的:
- 行程规划Server:负责根据槽位生成行程草案,内部可能调用大模型或规则引擎
- 库存Server:对接航班、酒店、门票的库存查询接口
- 价格Server:负责比价、优惠计算、总价汇总
- 订单Server:负责创建订单、查询订单状态、取消订单
- 支付Server:负责生成支付链接、查询支付结果、处理退款
每个Server独立部署,独立扩缩容。库存查询是高频只读,可以多开实例;支付是低频但关键,单独部署并加更严格的监控。这种拆分让故障隔离变得清晰——库存接口挂了,用户至少还能看行程和下单,不至于整个系统瘫痪。
4.3 工具描述怎么写才不容易被模型误用
MCP工具的描述(description)直接决定模型会不会正确调用。我总结了几条实战经验:
第一,描述里必须写清楚"什么时候用"和"什么时候不用"。比如"查询航班"这个工具,描述里要写"当用户已明确出发地、目的地、日期时调用;若缺少任一信息,先向用户询问,不要猜测"。
第二,参数schema要严格。日期用ISO格式,人数用整数并设最小值,枚举值列全。模型看到严格的schema,乱填的概率会低很多。
第三,返回结果要结构化且带状态码。不要返回一大段自然语言,返回JSON,包含success、data、error_code。这样Agent能根据error_code决定是重试、换工具还是告诉用户失败。
{ "name": "search_flights", "description": "查询指定航线的航班。仅当出发地、目的地、日期三者齐全时调用。缺少信息时先询问用户。", "inputSchema": { "type": "object", "properties": { "origin": { "type": "string", "description": "出发城市三字码或中文名" }, "destination": { "type": "string" }, "date": { "type": "string", "format": "date" }, "passengers": { "type": "integer", "minimum": 1 } }, "required": ["origin", "destination", "date", "passengers"] } }4.4 工具调用的超时与降级
第三方接口不稳定是常态。我给每个MCP工具调用都设了超时(一般3到5秒),超时后不是直接报错,而是走降级逻辑。比如库存查询超时,就返回缓存里的最近一次结果并标注"数据可能不是最新";价格计算超时,就用估算公式给个区间价,让用户先看方案,确认时再实时算。
注意:降级返回的数据一定要带标记,前端要明确告诉用户"这是预估/缓存数据",否则用户按缓存价下单,实际价格对不上,投诉就来了。
5. 支付层:从下单到回调的完整闭环
5.1 支付为什么是整条链路最容易出事的地方
对话和工具调用出错,用户顶多觉得"这AI不太聪明";支付出错,那是真金白银的问题,轻则用户付了钱订单没生成,重则重复扣款、对不上账。我见过最离谱的案例是回调没做幂等,用户网络抖动重试了一次,结果扣了两笔。
支付层的核心原则就三条:订单先于支付、回调必须幂等、状态以服务端为准。展开说就是:用户点支付前,服务端必须先创建订单并落库,拿到订单号;支付回调可能重复到达,处理逻辑必须用订单号做幂等;前端展示的支付状态永远以服务端查询结果为准,不能信前端传回来的"我付好了"。
5.2 支付流程的时序拆解
完整的支付闭环我拆成六步:
- 用户在对话里确认行程,Agent调用订单Server创建订单,状态为
pending_payment - 订单Server调用支付Server,生成支付单,拿到支付链接或二维码
- 支付链接通过对话流返回给前端,用户点击或扫码
- 用户在支付渠道完成付款
- 支付渠道异步回调支付Server,支付Server验签、幂等处理后更新支付单状态,并通知订单Server
- 订单Server把订单状态改为
paid,触发后续履约(发确认短信、通知供应商)
这里第5步的验签和幂等是重中之重。验签是确认回调真的来自支付渠道而不是伪造的;幂等是防止同一笔回调被处理多次。我的做法是用支付单号做唯一索引,回调进来先尝试插入一条回调记录,插入成功才继续处理,插入冲突说明已处理过,直接返回成功。
def handle_payment_callback(payload, signature): # 1. 验签 if not verify_signature(payload, signature): return {"code": "FAIL", "msg": "invalid signature"} # 2. 幂等:以支付单号+回调流水号做唯一约束 try: db.insert("payment_callbacks", { "payment_no": payload["out_trade_no"], "channel_trade_no": payload["transaction_id"], "raw": json.dumps(payload) }) except DuplicateKeyError: return {"code": "SUCCESS", "msg": "already processed"} # 3. 更新支付单与订单状态 update_payment_status(payload["out_trade_no"], "paid") update_order_status_by_payment(payload["out_trade_no"]) return {"code": "SUCCESS"}5.3 支付渠道选型与聚合
实际项目里很少只接一个渠道,通常要支持多种支付方式。我的建议是接一个聚合支付层,把不同渠道的差异屏蔽掉,上层订单系统只跟聚合层打交道。聚合层负责:统一的下单接口、统一回调格式、统一对账文件解析。
选型时重点看三件事:费率、结算周期、对账能力。费率差零点几个点,量大了一年差不少钱;结算周期影响现金流;对账能力最容易被忽略,但真出问题时,能不能快速拉出"平台订单 vs 渠道流水"的差异明细,决定了你要花一小时还是三天去查账。
5.4 支付状态查询与补偿
回调不是100%可靠的,网络问题、渠道侧故障都可能导致回调丢失。所以必须有一个主动查询补偿机制:对于状态还是pending_payment但创建超过一定时间(比如5分钟)的订单,定时任务主动去渠道查询真实状态,查到已支付就补上状态更新。
这个补偿任务我一般5分钟跑一次,查最近24小时内未完成的订单。跑之前一定要加分布式锁,避免多实例重复处理。补偿逻辑和回调逻辑要复用同一套状态更新函数,保证行为一致。
6. 常见问题与排查技巧实录
6.1 对话层典型问题
问题一:流式输出到一半断了,用户看到半句话。排查思路:先看是SSE连接被网关超时切断,还是后端生成异常。网关超时一般设60秒,如果单次生成超过这个时间就会被切。解决办法是把长任务拆成多段,或者用心跳包保持连接。我遇到过Nginx默认proxy_read_timeout是60秒,改成300秒后问题消失。
问题二:多轮对话上下文串了。典型表现是用户换了话题,Agent还在回答上一个话题。根因通常是session_id复用错误,或者槽位没清空。排查时打印每次请求带的session_id和槽位快照,一眼就能看出问题。
6.2 MCP层典型问题
问题一:模型不调用工具,直接编答案。这是最常见的。原因通常是工具描述不够明确,或者系统提示词没强调"必须基于工具返回作答"。我的做法是在系统提示里明确写"涉及价格、库存、订单状态的信息,必须调用对应工具获取,禁止凭记忆回答"。
问题二:工具调用参数格式错误。比如日期传成"下个月"而不是ISO格式。解决办法是在schema里加format约束,同时在工具实现里做一层参数清洗,能自动纠正的自动纠正,纠正不了返回明确错误让模型重试。
问题三:工具调用死循环。模型反复调用同一个工具,因为返回结果它不满意。必须设最大调用轮数(我一般设8轮),超过就强制结束并告诉用户"暂时无法完成,请稍后重试"。
6.3 支付层典型问题速查表
| 现象 | 可能原因 | 排查方向 | 解决 |
|---|---|---|---|
| 用户付了钱订单还是待支付 | 回调丢失 | 查渠道回调日志、查补偿任务是否运行 | 手动触发查询补偿 |
| 同一订单扣了两次 | 回调未幂等 | 查回调记录表是否有重复 | 加唯一索引,补退款 |
| 回调验签失败 | 密钥配置错误或编码问题 | 对比渠道文档的验签规则 | 修正密钥和编码 |
| 支付链接打不开 | 链接过期或域名被拦 | 检查链接有效期和域名 | 重新生成链接 |
| 对账金额对不上 | 手续费计算差异或退款未同步 | 拉渠道对账文件逐笔比对 | 修正对账逻辑 |
6.4 几条用血换来的避坑经验
第一,支付相关的所有操作都要有日志,且日志要能串起来。订单号、支付单号、渠道流水号,三个号在每条日志里都要有,出问题时才能快速定位。
第二,测试环境一定要能模拟回调。很多渠道的测试环境回调不稳定,我一般自己写一个模拟回调的工具,能手动触发各种场景(成功、失败、重复、验签失败),这样测试覆盖才全。
第三,金额一律用整数分存储,不要用浮点数。0.1加0.2不等于0.3这种事在支付里是灾难。数据库存分,展示时再除以100。
第四,退款逻辑要提前设计。很多人只想着收款,上线后用户要退款才发现没做。退款涉及原路退回、部分退款、退款状态查询,复杂度不比支付低,一定要在架构阶段就留好位置。
7. 我对这套技术栈的一点个人体会
做AI旅游Agent这两年,我最大的感受是:难的不是AI,是工程。模型能力已经足够支撑这类场景,真正决定项目成败的是那些"不性感"的部分——上下文管理、工具调度、支付幂等、异常补偿。我见过太多团队把精力全花在调prompt上,结果上线后栽在支付回调上。
如果让我给正在做类似项目的朋友一句建议,那就是:先把交易链路跑通跑稳,再回头优化对话体验。因为对话体验差用户会吐槽但还会用,支付出问题用户直接流失还可能投诉。架构上把交易层独立出来、把MCP工具标准化、把状态管理显式化,这三件事做扎实了,后面加功能会越来越顺;反之,前期图快堆在一起,后期每加一个功能都是牵一发动全身。
最后分享一个我最近在用的调试小技巧:给Agent的每一次工具调用都打上trace_id,前端、编排层、工具层、支付层的日志都用这个trace_id串起来,配合一个简单的日志查询页面,排查问题时输入trace_id就能看到完整链路。这个投入不到一天,但省下的排查时间是以周计的。