ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

AI旅游Agent技术拆解:从对话到支付的全链路架构

AI旅游Agent技术拆解:从对话到支付的全链路架构

1. AI旅游Agent的整体架构与设计思路

1.1 为什么选择Agent形态做旅游产品

先说背景。我最近在做一个面向自由行用户的AI旅游Agent,产品形态很简单:用户在一个聊天窗口里输入“帮我规划一个杭州三天两晚的行程”,Agent自动完成路线规划、酒店比价、门票预订甚至支付下单。整个项目走通之后,我发现它和传统的“AI客服”“聊天机器人”完全不同,它背后是一个从对话到交易的全链路系统。

为什么非要做成Agent形态而不是做一个App?核心原因在于旅游决策的复杂性。传统OTA(在线旅行社)的交互模型是“搜索-筛选-下单”,用户需要自己在一堆筛选条件里折腾,比如“价格从低到高”“评分4.5以上”“距离西湖2公里以内”。但真实用户的表达方式是模糊的:“预算别太高”“住的地方离地铁近一点”“第一天不要太赶”。这些约束条件天然适合用自然语言表达,而Agent的作用就是把模糊的语言转成结构化的搜索条件,再根据用户的实时反馈逐步收敛。

另外一个原因是旅游订单的“长链路”特征。一次完整的旅行服务往往包含多个环节:查航班、订酒店、规划路线、买门票、约车。用户如果在传统产品里操作,需要在多个页面、多个App之间来回切换,体验断链感很重。Agent可以把这些环节串起来,以“会话”为单位管理整个旅程。

所以这个项目的技术栈设计围绕三个核心目标展开:让对话层足够轻、让工具调用足够标准化、让交易闭环足够可靠。

1.2 整体技术栈与分层设计

我把系统拆成了五层,从用户那一端开始:

第一层是前端对话层,负责聊天UI、流式渲染、卡片组件、会话快照。第二层是对话编排层,负责意图识别、槽位填充、多轮状态管理。第三层是Agent核心层,负责决策规划、工具选择、上下文管理。第四层是MCP接入层,负责统一封装外部能力,比如酒店搜索、机票查询、订单创建。第五层是业务系统层,包含用户系统、订单中心、支付网关、对账服务。

这套分层的核心理念是“对话与业务解耦”。对话层只关心用户说了什么,不关心背后调用哪家酒店的接口;MCP层只关心怎么把外部能力包装成标准工具,不关心对话流程怎么编排。

技术选型上,前端用的是React + TypeScript,对话编排用的是LangGraph再加一层自定义的状态机,Agent核心基于LLM做ReAct模式的循环推理,MCP层用TypeScript写了两套Server(一套查酒店,一套查票务),支付走的是微信支付Native + 云支付网关的桥接。

从结果看,这个架构最大的收益是“可替换性”。比如最初接的是某家聚合机票API,后面换成另一家直连渠道,只需要重写对应的MCP Server,上层代码完全不用动。这也是我建议任何做AI应用的同学从一开始就做好分层的根本原因——AI项目迭代太快,今天选的供应商明天可能就没了,或者价格模型变了,如果你把所有逻辑都揉在一条链路上,替换成本会搞得你怀疑人生。

1.3 几个容易被忽略的架构决策

有一个决策特别想说:会话对象的设计。旅游Agent里,一次“对话”不是简单的一问一答,而是一次完整的、可能跨好几天的服务过程。用户今天说“帮我查下周去青岛的高铁”,明天回来说“还是改成坐飞机吧”,后天又追加一句“顺便看看青岛站附近的酒店”。这意味着系统里必须有一个持久化的会话对象,它不只是一个消息列表,还包含当前行程的完整状态、已确认的订单、待用户确认的选项、收集到一半的约束条件。

我把这个对象设计成一颗“行程树”,树上有行程节点、住宿节点、交通节点、票务节点。每个节点有自己的生命周期(草稿、待确认、已确认、已取消)。对话层的每一次新消息,都相当于在这棵树上做一次增量修改。

有人说这太复杂了,还不如让LLM每次重新推理一遍。实测下来,短期可以,但一旦涉及支付和订单状态,必须靠可靠的数据结构兜底,不然LLM的幻觉会让你付出真金白银的代价。

2. 前端对话层的实现要点

2.1 流式对话与打字机效果

前端聊天界面是整个用户体验的第一接触点,它面临的第一个技术挑战就是“流式输出”。大模型返回内容不是一次性到位的,而是一个token一个token地吐出来。如果等全部生成完毕再渲染,用户会感觉系统“卡死了”;但如果直接把每次增量直接怼到DOM里,浏览器在长文本场景下会越来越卡。

我采用的方案是增量文本 + 消息边界稳定。

具体来说,前端通过WebSocket接收服务端推送的增量事件,每收到一个事件就往当前消息的文本缓冲区里追加内容。渲染层不做整条消息的重新渲染,只对新增的文本片段做一次性插入。在React里,我用了useMemo加上一个MessageText子组件,避免父组件因为输入框state变化导致整个消息列表重渲。

还有一个细节:WebSocket断线重连后的消息拼接。用户聊到一半网络断了,重新连上之后前端要把“本地已渲染的内容”和“服务端重放的完整消息”做合并。我的做法是给每条前端渲染的消息加一个clientMsgId,重连后服务端回放时带上相同ID,前端发现ID已存在就直接丢弃回放内容,避免重复渲染。

// 核心增量处理逻辑(简化) const handleStreamChunk = (chunk: StreamChunk) => { const msgId = chunk.messageId; setMessages(prev => { const idx = prev.findIndex(m => m.id === msgId); if (idx === -1) return prev; const updated = [...prev]; const oldText = updated[idx].text; updated[idx] = { ...updated[idx], text: oldText + chunk.delta }; return updated; }); };

这段代码看起来简单,但它背后的关键决定是“不要整条替换消息文本,只做追加”。如果你直接setMessages([...prev, newText]),每次增量都会触发一次完整的diff和重渲染,消息一长,页面就开始飘。用规定了key的列表项来隔离更新范围,性能会稳定很多。

2.2 卡片消息与富交互

旅游Agent不可能只用纯文本。查出来的酒店、航班、门票,都要以卡片形式展示,用户才能在卡片里直接操作(比如选酒店、比价、确认预订)。这要求聊天消息体支持结构化内容。

我实现的方案是“消息体JSON化”:每条消息有一个type字段,取值是text、card、carousel、form、order。文本消息就是纯文本渲染,卡片消息则是一个包含标题、图片、价格、操作按钮的结构化对象。前端根据类型分发到不同的渲染器。

{ "type": "hotel_card", "payload": { "hotelId": "HZ-00321", "name": "西湖边上的某某民宿", "price": 428, "currency": "CNY", "rating": 4.7, "location": { "lat": 30.245, "lng": 120.154 }, "distanceToWestLake": 850, "actions": [ { "label": "查看详情", "handler": "hotel.detail" }, { "label": "选择此酒店", "handler": "hotel.select" } ] } }

这里有个踩坑经验:卡片消息的按钮事件不要直接绑定具体的业务动作,而是绑定一个抽象的handler名称。比如上面handler: "hotel.select",前端收到点击后,把它作为一条用户消息发回给Agent(带上卡片上下文),Agent再做后续决策。

为什么这么做?第一,如果卡片按钮是直接调API的,那Agent就失去了一次“确认”的机会;第二,用户点按钮时可能已经改主意了,但卡片上展示的还是旧信息,如果直接调API可能会造成和真实意图的偏差。正确的路径是:点击按钮 -> 生成一条用户消息 -> Agent重新理解 -> 执行新动作。

这个设计在支付场景下尤其重要。支付按钮点击之后,绝不能是简单地“唤起支付”,而是要先生成一个支付确认请求,把订单信息重新校对一遍再发起支付。

2.3 会话状态管理与会话快照

旅游场景天然跨时长,用户可能在通勤路上查酒店、在午休时对比价格、在晚上才下单。前端必须支持“会话快照”能力:用户离开页面再回来,聊天的上下文不能丢。

我的做法是把消息列表和行程状态序列化到localStorage,同时在服务端做一份镜像。前端每次收到新的消息增量,就把整个会话快照写入localStorage,写入频率做了节流:增量消息即时写,行程状态每5秒写一次。

恢复会话时,前端从localStorage拉快照,按clientMsgId去重后渲染到页面上。同时调一个POST /session/restore接口把会话ID传给服务端,服务端返回最新的行程状态,前端和快照做合并。不一致时以服务端为准,因为服务端的行程状态才涉及资金。

localStorage有5MB上限,会话消息长了会爆。我的策略是只保留最近的200条消息明文,更早的消息用一条折叠记录替代(如“前面还有15条消息,点此展开”),展开时再从前端缓存服务拉取。这样既保证恢复速度快,又不影响体验。

2.4 断线重连与心跳机制

WebSocket在移动端极其不稳定,尤其是用户在地铁里。必须设计重连机制和心跳保活。

我用了标准的指数退避策略:断线后第1秒重试、第2秒、第4秒……最多间隔30秒。每次重连成功之后,发一个session.sync请求获取消息游标,服务端回放游标之后的所有增量。

心跳层面,前端每15秒发一个ping包,收到pong说明链路正常;连续3次pong超时,主动断开连接触发重连逻辑。

一个容易被忽略的地方:WebSocket的URL必须带sessionId和userId,否则重连后服务端不知道你是谁。但鉴权信息不要拼在URL query里,容易被日志打出来。我用的方案是:鉴权放进首次连接的消息体中,服务端校验通过后签发一个短期token,后续重连都带这个短期token。

3. MCP层:Agent与外部能力的连接器

3.1 MCP协议到底解决了什么问题

MCP(Model Context Protocol,模型上下文协议)对我而言,最大的价值不是“标准化”本身,而是把“让AI使用工具”这件事从工程拼装变成了组件化开发。

在没有MCP之前,Agent要调用外部API,典型做法是给LLM写一堆函数定义(Function Calling),然后在代码里手写if (functionName === 'searchHotel')这样的一堆分支。每接一个新API,就要改Agent核心逻辑,加分支、加参数映射、加返回值处理。时间一长,Agent核心代码里全是业务逻辑,改一处就牵一发动全身。

MCP的模型是:Agent通过标准协议去发现和调用“工具”。每个工具是一个MCP Server,对外暴露一组定义清晰的操作。比如我的酒店MCP Server暴露了这些工具:search_hotels(查酒店)、get_hotel_detail(查详情)、book_hotel(预订)、cancel_booking(取消)。

Agent端做的事情变得很单纯:从MCP Server拉取工具列表,根据用户请求决定调用哪个工具,传入参数,拿到结果,再组织语言回复。新增能力,只需要新写一个MCP Server,或者往现有Server里加工具,Agent端零改动。

“写代码的人不需要关心模型API”“模型不需要关心业务系统”这两个关注点被MCP清晰地分离了。

3.2 旅游场景下MCP Server的搭建

我写了两个MCP Server,一个是酒店Server,一个是票务Server。每个Server都是一个独立进程,通过stdio和Agent进程通信(也可以用SSE模式,但我本地开发用stdio,生产环境用SSE)。

先看一个工具的完整定义,以“查酒店”为例:

{ "name": "search_hotels", "description": "根据城市、入住日期、离店日期、人数等条件搜索符合要求的酒店列表", "inputSchema": { "type": "object", "properties": { "city": { "type": "string", "description": "城市名,如杭州" }, "checkIn": { "type": "string", "format": "date" }, "checkOut": { "type": "string", "format": "date" }, "guests": { "type": "integer", "minimum": 1, "maximum": 6 }, "priceRange": { "type": "object", "properties": { "min": { "type": "number" }, "max": { "type": "number" } } } }, "required": ["city", "checkIn", "checkOut", "guests"] } }

这套定义看起来简单,但里面的不少坑值得一提。

第一个坑是日期格式一定要规范。LLM填充参数时经常会输出“下周三”“周末”“明天”这类相对日期。我在MCP Server入口加了解释器,把这类相对日期标准化成YYYY-MM-DD格式。这个过程可以由LLM自己完成,但为了可靠性和快速调试,我选择了在Server端用moment.js做规则解析,LLM只负责输出语义化字段(如relativeDate: "nextThursday"),Server再转成具体日期。

第二个坑是坐标搜索和关键词搜索的混合。用户说“西湖附近”,这不是一个标准的地名。我的做法是先把这类地标名称翻译成经纬度半径,再丢给酒店搜索API。这个翻译动作本身也可用LLM完成,但我更倾向在MCP Server里接一个地理编码服务,因为准确率和响应速度都更稳定。

最终我的Hotel Server内部逻辑是:接收Agent传来的城市和地标关键词 -> 调地理编码服务拿到坐标 -> 把坐标和酒店查询参数一起传给上游API -> 对上游结果做个过滤和排序(比如按评分、距离、价格综合排序)-> 返回结构化列表。

3.3 工具调用链路的错误处理

MCP Server不是万能的,它后面接的是第三方API,第三方API的返回经常不按文档来。我在Server层做了一层“结果规整器”,把上游的返回统一转成Agent可以理解的格式,同时做好三种错误状态的处理:

  • 无结果:返回{ "status": "empty", "message": "该条件下没有符合条件的酒店,建议放宽价格范围或日期限制" }
  • 部分失败:比如查询酒店成功但查交通失败,返回{ "status": "partial", "data": { ... }, "error": { "component": "transport", "message": "..." } }
  • 完全失败:上游API超时,返回{ "status": "failed", "error": "...", "retryable": true }

第三类错误很重要,retryable标记告诉Agent核心:“这个工具这次没成功,但你可以稍后重试”。Agent拿到这个标记后,可以在对话里继续追问用户:“交通查询暂时不可用,要不先看酒店?”

有人说“这不就是把异常处理吗”?对,但它不是普通的异常处理,因为错误信息是给LLM看的,不是给程序员看的。LLM需要从错误信息里推断出如何继续对话,所以错误描述必须用自然语言写清楚,不能直接抛一个500 Internal Server Error让LLM一脸懵。

4. 业务编排:从对话到下单

4.1 基于状态机的会话编排

对话编排层是整个系统里最复杂的一块。因为旅游不是一次性的工具调用,而是多轮对话 + 跨多天的过程性任务。

我用了LangGraph做基础编排,再加一层自定义状态机管理行程生命周期。LangGraph负责“当前这轮对话该如何推进”,自定义状态机负责“整个行程处于什么阶段”。

行程状态机的定义:

IDLE -> COLLECTING -> PLANNING -> PROPOSAL -> CONFIRMING -> BOOKED -> PAYING -> PAID -> COMPLETED PLANNING -> AMENDING(用户修改计划) CONFIRMING -> IDLE(用户取消/放弃) PAYING -> PAY_FAILED(支付失败) CONFIRMING -> BOOKED -> PAYING(确认订单并进入支付)

每个状态之间定义了合法迁移条件。比如从CONFIRMING到BOOKED,必须要求用户明确表达“确认预订”并且系统侧成功创建了订单;从PAID到COMPLETED,必须要求支付回调已到达并且订单状态已落库。

为什么需要状态机而不是完全依赖LLM?因为LLM天然有幻觉倾向,它可能自作主张地认为“订单已经支付了”。状态机像一个护栏,把LLM的输出限制在合法动作之内。LLM的输出先经过一次“意图解析”,解析结果是一个结构化动作(如amend_schedule、confirm_booking、select_hotel),状态机校验这个动作在当前状态下是否合法,合法才执行。

4.2 多轮对话的信息收集与槽位填充

旅游Agent经常会遇到信息不全的情况。用户说“帮我订下个月的酒店”,但没说城市、没说日期、没说人数,这时候Agent必须追问。

我维护了一个“约束槽位表”,每个槽位代表一个完成预订所必需的参数。槽位有两类:必需槽位和可选槽位。必需槽位包括:目的地、入住日期、离店日期、入住人数。可选槽位包括:价格范围、偏好区域、酒店类型(星级/民宿)、特殊需求(亲子、宠物、无烟)。

每次用户消息进来,经过LLM的槽位提取,把能填的槽位值提取出来更新状态。如果所有必需槽位都齐了,生成方案;如果缺,就生成追问问题。

一个细节:追问问题时不要一次把所有缺的都问出来。用户说“帮我去杭州玩三天”,你反问“请问您哪天入住、哪天离店、几位入住?”,用户会觉得你在背问卷。更好的方式是基于常识先给一个默认方案,再让用户修正。比如Agent先回复“杭州国庆期间的酒店需要提前订,我建议您10月1日入住3号离店,2个人,价格区间我先按500-800元每晚来查,如果不对您随时纠正我。”

这个“先给默认值再修正”的策略,把用户的认知负担降到了最低。实测下来,用户的完成率比“填空式对话”高很多。

4.3 从方案到订单的转换

当用户确认一个酒店方案后,系统需要从“对话世界”跳转到“业务世界”。这一步的可靠性至关重要。

我设计了“双阶段确认”机制。第一阶段:用户点“选这个酒店”,Agent生成一个待确认订单草稿,展示在卡片上,包含酒店信息、价格、日期、取消政策。第二阶段:用户点“确认预订”,系统调用MCP Server的book_hotel工具,真正创建订单。

这个双阶段的中间状态很重要,因为用户在“选”和“确认”之间可能改变想法,也可能因为看到了取消政策而犹豫。我把这两个动作分开,避免用户误操作造成真实订单。

创建订单成功后,系统生成一个订单对象。订单对象包含:订单号、酒店ID、日期范围、价格、状态、关联的会话ID。会话ID是连接对话和业务的关键字段。

5. 支付流程的技术实现

5.1 支付渠道选择与统一网关

支付是整个链路中技术含量最高、容错要求最严格的一环。我做了一个统一支付网关,对上层屏蔽具体支付渠道的区别。

支付网关的核心是一个接口:

POST /payment/create Body: { orderId, amount, currency, channel, notifyUrl, meta } Response: { paymentId, status, payParams }

channel支持微信支付Native(PC扫码)、小程序支付、余额支付等。payParams根据渠道不同返回不同的参数:微信Native返回一个code_url(二维码链接),小程序返回wx.requestPayment需要的参数包。

为什么做统一网关?因为我不确定哪个渠道最终能跑通。先接微信,后面可能接支付宝、云闪付。网关化之后,业务层只需要关心“发起支付”和“接收回调”两个动作,不关心具体渠道细节。

5.2 支付回调与签名验证

支付回调是系统可靠性最容易出问题的地方。我总结的核心原则是:回调消息不是业务消息,支付结果以回调为主,但前端轮询转账轮询服务端主动查单为辅。

流程是这样的:

  1. 用户在聊天卡片里点“确认支付”,前端调用POST /payment/create,拿到渠道的支付参数。
  2. 前端唤起支付(微信扫码或者小程序支付)。
  3. 支付完成后,微信服务器往配置的notifyUrl发一个异步回调通知。
  4. 网关收到回调后,先验证签名,再用outTradeNo(商户订单号)查一次微信支付接口确认订单状态,双确认一致后才更新状态。
  5. 订单状态更新后,通过WebSocket给前端推一条order.paid消息,聊天界面出现支付成功的确认卡片。

签名验证是安全底线。微信支付的回调签名用的密钥要放在服务端,绝对不能出现在前端任何代码或配置里。我见过有人图方便,把商户API密钥直接写在前端配置文件里,这是灾难性的错误。任何有密钥的文件一旦被用户下载到浏览器,就等于把整个商户号的资金安全交出去了。

5.3 AI场景下的支付确认交互

AI场景下支付有一个特殊问题:用户可能在对话里直接说“帮我付了”。这句话让LLM产生调用支付工具的意向。但是支付涉及真实资金,绝不能因为一句话就直接扣款。

我在支付动作前面加了一道“人工确认屏障”。LLM调用支付工具的请求会被拦截在网关层,网关返回一个REQUIRE_USER_CONFIRMATION的状态,同时生成一个确认卡片推给用户。卡片上明确展示订单金额、商品信息、支付方式,用户必须手动点击“确认支付”按钮,后端才会真正发起支付请求。

这道屏障的实现原理是:支付工具在MCP Server层不暴露confirm_payment,只暴露initiate_payment。initiate_payment的返回值永远不直接是支付链接,而是一个待确认状态的ID。前端拿到这个ID后,展示确认卡片。用户点确认后,前端发送一个带用户签名(比如二次输入密码或者人脸验证)的确认请求,才会真正唤起支付。

这个设计让我免去了一次险些因为LLM幻觉造成的资金风险。当时用户在对话里说“你帮我随便选个便宜点的酒店直接付了就行”,LLM真的触发了initiate_payment,幸好有这个屏障拦截,否则那笔订单就真的扣款了。

5.4 超时取消与退款对账

支付还有一个避免不了的工程问题:用户发起支付但没按时完成。微信支付的支付链接默认有效期是2小时(Native支付可以设置)。超时后订单如果还挂着,既占用了库存,也给用户带来困惑。

我做了两步处理。第一,定时任务每30秒扫描一次“待支付”状态的订单,如果超过有效期(通常是支付链接创建后2小时)就触发“超时取消”,订单置为取消状态,同时释放库存。第二,如果用户支付了但从未确认,系统要能在回调到达时判断订单状态,若订单已取消但回调显示支付成功,进入“退款待处理”队列,自动发起原路退款。

对账我采用的是每日一次的对账单比对。每日凌晨从支付渠道拉取前一天的交易流水,与本地订单表的支付记录做匹配。对不上的条目进入人工处理队列。这个环节在项目早期容易被忽略,等订单量上来了才发现,那时候补对账体系会非常痛苦。

6. 实战中的问题排查与避坑记录

6.1 前端流式渲染的性能坑

我一开始用了一个比较粗暴的做法:每次收到增量消息,用setMessages更新整个消息数组。当会话拉到300条以上时,页面明显变卡,打字机效果变成了“幻灯片播放”。

排查之后定位到两个问题:一是消息列表没有做列表项级别的隔离,任何一条消息更新都触发所有列表项重新渲染;二是文本内容更新时,整个消息组件的DOM被替换了,导致滚动位置丢失、输入框焦点丢失。

修复方案是:列表项外面套一个React.memo,每个消息组件的key用消息ID加版本号;增量更新时,只对目标消息的文本节点做插入操作(用requestAnimationFrame做节流),不更新整个消息对象。

6.2 MCP Server的资源管理与超时

MCP Server作为子进程运行时,资源管理是个隐性问题。最开始我用启动进程的方式管理Server,发现长时间运行后内存持续增长,最终导致Agent响应变慢。

后来排查发现是MCP Server里的连接池泄漏。查了一下上游API的SDK,它在每次请求后没有正确释放HTTP连接。解决方法是:在MCP Server里加了一层带超时控制的HTTP客户端,设置最大空闲连接数和每个请求的超时时间(酒店查询超时8秒,创建订单超时15秒),同时对每个工具调用做了Promise.race兜底。

有一个教训:不要把上游API的错误状态直接透传给LLM。有一次上游返回了“429 Too Many Requests”,LLM居然在回复里说“系统繁忙请稍后再试”,虽然这没毛病,但它没有理解“这其实是供应方接口限流,可以换一个供应商查询”。后来我在MCP Server错误处理里,把429翻译成了“本渠道暂无库存,可选择其他方式或稍后再试”,LLM才知道有“切换供应商”这个选项。

6.3 支付回调与前端状态的一致性

最让人头疼的bug是:支付成功回调已经到了服务端,订单状态也更新为“已支付”,但前端聊天界面还停在“等待支付”状态。用户以为没支付成功,又发起了一次新的支付。

根因是WebSocket推送的时序问题。支付回调线程把状态推给了session连接,但此时前端的WebSocket可能正处于临时断线状态,消息丢了。单纯等前端重连后再请求“消息列表”也解决不了,因为订单状态更新事件不是“消息”,它没有记录在会话消息表里。

修复方案:把“订单状态变更”也作为消息存进会话消息表。支付成功后,系统写入一条类型为order_event的消息(包含订单最新状态、金额、支付方式、时间戳),走正常的消息推送通道。这样即使WebSocket断线,前端重连后拉取最新消息列表时也能拉到这条order_event消息,状态自然就同步了。这也呼应了第三层设计里“业务事件必须进会话”的原则。

6.4 会话恢复时行程状态漂移

另一个高频问题:用户关闭浏览器后,重新打开页面,前端从localStorage恢复了快照,但服务端的行程状态可能已经变化了(比如用户在其他设备上完成了支付)。我处理的方式是:恢复快照时,不是取本地为准,而是先向服务端请求一次session.getLatest,把服务端返回的行程状态和本地快照做diff,任何差异以服务端为准。

这个看似简单的问题背后暴露了一个架构缺陷:本地快照在离线状态下可能被修改,但服务端无法感知。所以我在服务端对会话状态维护了一个基于修订号的并发控制机制。每次更新状态时检查修订号是否匹配,不匹配说明有其他写操作发生,直接拒绝当前更新并要求前端刷新。

6.5 多Agent并发下的状态竞争

用户可能会在同一个会话里开多个对话分支,比如一边在查酒店,另一边在问“杭州有什么好吃的”。这两个意图都会走到Agent核心,但它们操作的状态可能重叠,如果并发执行状态变更,很容易出现覆盖。

我的方案是:对话编排层对“写操作”做了串行化,同一个会话ID下,同一时间只允许一个写任务在执行。读操作可以并行,写操作排队。这个串行化是通过一个会话级的事务锁实现的,基于Redis的SETNX命令,拿到锁的任务执行完状态更新后释放锁。锁的超时设为10秒,超时自动释放,避免死锁。

7. 工具选型与后续扩展方向

7.1 前端框架与组件库的选择

前端我选了React + TypeScript,组件库用Ant Design,聊天界面是自定义组件。选React是因为生态成熟,聊天类交互需要大量的状态管理,Hooks模型非常顺手。TypeScript自然是标配,聊天消息体有复杂的类型结构(文本、卡片、订单、表单等等),类型系统可以大幅度减少低级错误。

如果你从零开始做,考虑到给用户的“项目复用参考”,我会推荐优先考虑Next.js服务端渲染,因为旅游类Agent页面——落地页、产品介绍页、登录页——这些边缘页面用服务端渲染对SEO更友好。核心聊天体验部分可以做成独立的React应用,甚至可以单独部署。

7.2 Agent框架选型对比

Agent核心层现在有很多选择,我实际比较过LangGraph、AutoGen、CrewAI、自研框架。简单说说我的选型结论:

  • LangGraph:适合需要精细控制流程的项目。它的图结构非常适合旅游场景的“有限状态 + 多分支决策”,但学习曲线陡。
  • AutoGen:适合多Agent对话协作,但重对话模式在“用户-Agent”这种简单二元交互里反而显得多余。
  • CrewAI:擅长多角色分工,但抽象层级太高,调试复杂问题时很难定位到具体环节。
  • 自研框架:灵活度最高,但开发和维护成本也最高。除非你是团队里有扎实的LLM工程背景,否则不建议一步到位自研。

我最终采用的是“LangGraph编排 + 自研状态机 + MCP工具层”的组合。LangGraph负责“这一轮该怎么走”,状态机负责“整个流程当前在哪”,MCP负责“能执行什么动作”。各司其职,边界清晰。

7.3 从单点功能到多城市多场景的扩展

目前这个Agent已经能处理“查酒店”“规划行程”“预订门票”三类核心场景,运行稳定。接下来有一批值得探索的扩展方向:

第一个方向是多供应方聚合比价。目前酒店数据来自一个聚合渠道,价格和库存并不是全量最优。接入更多MCP Server之后,以统一的search_hotels接口对齐各家供应商,然后做比价排序,这能让用户体验提升到一个新量级。

第二个方向是主动感知型Agent。比如用户发出了机票订单,Agent可以主动监测航班延误信息,在延误发生时提醒并给出改签建议。这要求MCP Server增加“订阅类”能力(比如航班状态推送),Agent层也要增加常驻任务机制。

第三个方向是支付渠道的完善。目前只接了一个支付渠道,后续要把支付宝、云闪付以及跨境支付(如海外卡消费)都接入统一网关。网关已经预留了渠道适配器接口,理论上每个新渠道只要实现一套createPayment/queryPayment/handleNotify接口即可。

第四个方向是记忆能力的深化。用户第二次来咨询“成都”时,系统能不能记得“这位用户上次去杭州住的是高星级酒店、预算偏高端”?这需要维护一个“用户旅行偏好画像”,在对话编排层的槽位提取阶段就把长期的隐式偏好更新进来。这个方法如果能沉淀下来,产品的壁垒就会从“工具调用能力”跃升到“个性化服务能力”。

8. 最后的几句实在话

做这个AI旅游Agent项目,我最深的体会是:AI应用的技术难点,从来不在“AI”本身,而在AI之外的那一层工程。

纯对话、纯生成、纯工具调用,这些有模型和框架都能搞定,花不了太多时间。真正花掉我大部分精力的是那些平时不起眼的地方:流式绘制怎么做到不卡顿、WebSocket断了怎么拼消息、状态机怎么防止LLM乱来、支付回调怎么和前端状态保证一致、订单超时怎么处理才能不留坑。

如果你也在做类似的Agent项目,我的建议很实在:先把“业务闭环”定义清楚,再谈技术栈。一个旅游Agent如果只做到“能聊天、能推荐”,那确实不需要这么复杂;但一旦涉及到真实订单和真实资金,设计边界就非常重要了。你在状态机上多花的时间,在支付屏障上多花的时间,在数据一致性上多花的时间,都是在给用户的钱包和你的信誉上保险。

最后分享一个小技巧:给所有外部API调用加上一个“可回放”的日志系统。每次MCP Server调用上游API,都记录下完整的请求参数和响应原文。调试LLM幻觉问题、排查支付对账差异、复现用户当时看到的结果时,这套日志的价值远超预期。我踩过几次因为没有日志而在周末晚上对着生产环境干瞪眼的坑之后,就再也不敢省这块功夫了。

做AI Agent项目,永远要在“快速迭代”和“可靠兜底”之间找平衡。希望这篇拆解能让你少走一些我走过的弯路。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进