
1. 项目缘起与整体架构拆解去年年底我接了个私活帮一家做定制游的工作室搭一套AI旅游助手。需求听起来不复杂用户用自然语言描述想去哪、几天、预算多少、偏好什么系统给出可执行的行程方案并且能直接下单支付。但真动手才发现这东西横跨了对话系统、工具调用协议、第三方服务集成、支付链路四个完全不同的技术域任何一个环节掉链子用户体验就是灾难。市面上讲AI Agent的文章不少但大多停留在“提示词怎么写”或者“怎么接一个大模型API”的层面。真正把前端对话、MCP工具层、支付闭环串起来讲清楚的几乎没有。所以我把这次项目的完整技术栈拆开从用户敲下第一句话开始一直讲到钱进账、订单落库把每个环节的选型理由、踩过的坑、能直接抄的配置都写出来。这篇文章适合三类人看一是正在做或打算做AI应用的全栈开发者二是对MCP协议感兴趣但还没实际用过的人三是需要把AI能力和交易链路打通的业务方。不管你之前有没有接触过Agent开发跟着走一遍应该都能理解整套系统是怎么转起来的。先给一个全局视角。整个系统分四层对话交互层负责接收用户输入、维护多轮上下文、流式展示回复Agent编排层负责意图理解、任务规划、决定调用哪些工具MCP工具层是核心枢纽把地图、酒店、航班、天气等外部能力封装成标准化接口供Agent调用交易支付层处理订单生成、支付发起、回调确认和状态同步。四层之间通过明确定义的协议通信每层可以独立替换和扩展。为什么这么分层因为旅游场景的需求变化太快。今天接携程的酒店接口明天可能换成飞猪今天用支付宝明天可能要加微信支付。如果把这些能力硬编码在Agent逻辑里改一处就要动全身。分层之后每层只关心自己的职责通过接口契约解耦维护成本大幅降低。2. 前端对话层流式交互与状态管理2.1 为什么选SSE而不是WebSocket对话层最核心的需求是流式输出。用户问“帮我规划一个三亚五日游”模型不可能等全部生成完再一次性返回那样等待时间太长体验很差。需要像打字机一样逐字吐出来。实现流式输出有两条路WebSocket和SSEServer-Sent Events。我最终选了SSE理由如下。WebSocket是双向通信适合需要客户端频繁向服务端推送数据的场景比如聊天室、协同编辑。但旅游Agent的对话模式本质上是“用户发一条服务端流式回一条”客户端不需要在服务端生成过程中持续推送数据。SSE基于HTTP协议天然支持断线重连实现简单浏览器兼容性好服务端用普通的HTTP框架就能支持。具体实现上前端用EventSource接收流式数据。但EventSource有个限制只支持GET请求不能带请求体。对话内容通常比较长放URL参数里不合适。解决方案是用fetch配合ReadableStream手动解析SSE格式的数据流。这样既能用POST发送对话内容又能逐块读取服务端返回的文本。const response await fetch(/api/chat, { method: POST, headers: { Content-Type: application/json }, body: JSON.stringify({ message: userInput, sessionId }) }); const reader response.body.getReader(); const decoder new TextDecoder(); while (true) { const { done, value } await reader.read(); if (done) break; const chunk decoder.decode(value, { stream: true }); // 解析SSE格式data: {...}\n\n const lines chunk.split(\n); for (const line of lines) { if (line.startsWith(data: )) { const data JSON.parse(line.slice(6)); appendToChat(data.content); } } }注意SSE的每条消息以两个换行符结尾解析时不能简单按单换行分割否则会把一条消息拆成多块。稳妥的做法是维护一个缓冲区按\n\n分割完整消息后再处理。2.2 多轮上下文的管理策略旅游规划天然是多轮对话。用户先说“想去日本”然后补充“预算一万左右”再说“带小孩”每一轮都在细化需求。如果每轮都把完整历史传给模型token消耗会迅速膨胀而且早期无关信息会干扰模型判断。我的做法是滑动窗口加摘要压缩。保留最近N轮完整对话N通常取6到8更早的对话用模型生成一段摘要把关键约束条件提取出来。比如“用户计划去日本预算一万元携带一名儿童偏好亲子友好型景点”。这样既保留了核心信息又控制了token量。具体实现时摘要不是每轮都重新生成而是当对话轮次超过阈值时触发一次压缩。压缩后的摘要作为系统提示的一部分注入下一轮对话。实测下来这种方式比全量历史节省约60%的token而行程规划的准确率几乎没有下降。还有一个细节用户约束的结构化提取。旅游场景有一些关键槽位比如目的地、天数、预算、人数、出行日期、偏好标签。我会在每轮对话后让模型以JSON格式输出当前已确认的槽位信息前端用一个侧边栏展示“已收集的需求”用户能直观看到系统理解了什么也方便手动修正。这个设计大幅减少了“答非所问”的情况。2.3 前端状态管理的坑对话界面看起来简单但状态管理比想象中复杂。至少需要维护这几类状态消息列表包括用户消息、AI回复、工具调用中间态、流式输出中的临时文本、槽位信息、加载状态、错误状态。我一开始用React的useState管理很快就乱了。流式输出时每收到一个chunk就更新状态导致组件频繁重渲染输入框卡顿。后来换成useReducer加useRef的组合流式文本先写入ref用requestAnimationFrame节流更新到state渲染频率从每秒几十次降到每秒最多60次流畅度明显改善。另一个坑是消息ID的生成。流式输出时AI回复的消息ID需要在第一个chunk到达时就确定后续chunk追加到同一条消息上。如果等流结束再生成ID中间态的消息无法正确关联。我的做法是服务端在流开始时先发一个message_start事件携带消息ID后续的content事件都带上这个ID前端据此归并。3. MCP工具层Agent的能力扩展枢纽3.1 MCP到底是什么为什么需要它MCP全称Model Context Protocol是一个让AI模型与外部工具、数据源标准化交互的协议。你可以把它理解成“AI世界的USB接口”——以前每个工具都要写一套适配代码现在只要实现MCP协议任何支持MCP的Agent都能直接调用。在旅游Agent里需要调用的外部能力很多地图服务查景点位置和路线、酒店接口查房态和价格、航班接口查班次、天气接口查目的地气候、支付接口发起交易。如果每个都硬编码代码会变成一团乱麻。用MCP封装后Agent只需要知道“有哪些工具可用、每个工具需要什么参数”具体怎么调用、怎么鉴权、怎么处理错误都由MCP服务器负责。MCP的核心概念有三个Resources资源供模型读取的数据、Tools工具模型可以调用的函数、Prompts提示模板预定义的交互模式。旅游场景主要用Tools比如search_hotels、get_weather、plan_route每个工具定义好输入参数和输出格式Agent根据用户需求决定调用哪个。3.2 工具定义与参数设计定义一个MCP工具需要描述清楚三件事工具名称、功能说明、参数schema。功能说明是给模型看的要写得足够清晰模型才能判断什么时候该调用它。参数schema用JSON Schema描述包括参数类型、是否必填、取值范围。以酒店搜索工具为例{ name: search_hotels, description: 根据城市、入住日期、退房日期、人数搜索可用酒店返回酒店名称、价格、评分、位置信息, inputSchema: { type: object, properties: { city: { type: string, description: 城市名称如三亚 }, check_in: { type: string, format: date, description: 入住日期YYYY-MM-DD }, check_out: { type: string, format: date, description: 退房日期YYYY-MM-DD }, guests: { type: integer, minimum: 1, description: 入住人数 }, max_price: { type: number, description: 每晚最高价格单位元 } }, required: [city, check_in, check_out, guests] } }这里有个经验参数描述要具体最好带示例。模型对模糊描述的理解经常跑偏。比如city如果只写“城市”模型可能传“三亚市”也可能传“三亚”导致接口匹配失败。加上示例后模型倾向于传“三亚”和接口预期一致。另一个关键是错误处理。工具调用失败时返回给模型的信息要包含失败原因和可能的修正建议。比如酒店接口返回“日期格式错误”MCP服务器应该返回{error: 日期格式应为YYYY-MM-DD收到的是2024/1/1请修正后重试}。模型看到这个信息后会自动修正参数重新调用。这比直接抛异常让整个流程中断要好得多。3.3 多工具编排与依赖处理旅游规划往往需要多个工具协同。比如用户要“三亚五日游”Agent需要先查天气确定适合出行的日期再查航班确定到达时间然后查酒店最后规划每日行程路线。这些工具之间有依赖关系酒店搜索依赖确定的日期路线规划依赖酒店位置。MCP协议本身不处理工具间的依赖编排这是Agent层的职责。我的做法是在系统提示里明确告诉模型工具之间的逻辑关系并给出一个推荐调用顺序。同时在工具返回结果中保留足够的上下文信息方便模型做后续决策。比如酒店搜索结果里包含经纬度路线规划工具就能直接使用。实测中发现一个常见问题模型有时会并行调用有依赖关系的工具。比如同时调用酒店搜索和路线规划但路线规划需要酒店位置作为输入此时酒店还没搜出来路线规划只能瞎猜。解决办法是在工具描述里明确标注前置条件比如路线规划工具的描述里写“需要先获取酒店或景点的经纬度坐标”。模型看到这个约束后会倾向于按顺序调用。3.4 MCP服务器的部署与扩展MCP服务器可以本地运行也可以远程部署。本地运行的好处是延迟低、数据不出本机适合处理敏感信息。远程部署的好处是多个客户端共享、便于更新维护。旅游Agent的场景下我建议混合部署地图、天气这类公开数据用远程MCP服务器用户订单、支付信息这类敏感数据用本地MCP服务器。扩展新工具时只需要实现对应的MCP服务器在Agent配置里注册即可不需要改动Agent核心逻辑。这种插件式架构让系统能快速接入新的供应商。比如后来要加民宿搜索我只写了一个新的MCP服务器注册进去Agent就能自动发现并调用这个新工具。提示MCP工具的命名要有规律建议用动词_名词的格式比如search_hotels、get_weather、create_order。这样模型能更快理解工具用途也方便人工排查问题。4. 支付链路从订单生成到资金到账4.1 支付流程的整体设计旅游Agent的支付和普通电商支付有个本质区别订单内容是在对话中动态生成的。用户可能说“就按这个方案订”此时系统需要把对话中确认的行程酒店、航班、门票转化为标准订单计算总价然后发起支付。整个支付链路分五步订单生成、支付发起、用户支付、回调确认、状态同步。每一步都有坑我逐个说。订单生成阶段核心是把非结构化的对话内容转化为结构化的订单数据。我的做法是在Agent确认行程后生成一个订单预览包含每一项服务的名称、数量、单价、小计以及总价。这个预览展示给用户确认用户点“确认支付”后才真正创建订单。这样避免了用户随口一说就生成订单的情况。支付发起阶段调用支付平台的统一下单接口拿到支付凭证比如支付宝的订单字符串或微信的prepay_id前端据此唤起支付。这里的关键是订单号的设计必须全局唯一且能反查出订单内容。我用的是“业务前缀时间戳随机数”的格式比如TRIP20240115143022A3F7。4.2 支付回调的可靠性保障支付回调是整个链路最容易出问题的地方。用户支付成功后支付平台会向服务端发送异步通知服务端据此更新订单状态。但回调可能丢失、可能重复、可能延迟。防丢失的做法是双保险除了等待回调前端在支付完成后主动轮询订单状态。如果回调先到了轮询直接返回成功如果回调还没到轮询会触发一次主动查询向支付平台确认支付结果。这样即使回调丢失订单状态也能最终一致。防重复的做法是幂等处理。回调可能因为网络重试而多次到达每次都要检查订单当前状态。如果订单已经是“已支付”直接返回成功不重复处理。我用的是数据库的唯一约束加状态机订单状态只能从“待支付”变为“已支付”重复的回调会被状态机拒绝。防篡改的做法是签名验证。支付平台的回调会带签名服务端必须验证签名后才处理。这一步绝对不能省否则有人伪造回调就能白嫖订单。def handle_payment_callback(request): # 1. 验证签名 if not verify_signature(request.data, request.headers[signature]): return {error: invalid signature}, 400 # 2. 幂等检查 order Order.get(request.data[out_trade_no]) if order.status paid: return {status: ok} # 已处理过直接返回成功 # 3. 状态机流转 if order.status ! pending: return {error: invalid status}, 400 order.status paid order.paid_at request.data[pay_time] order.transaction_id request.data[transaction_id] order.save() # 4. 触发后续业务通知供应商、发送确认邮件等 trigger_fulfillment(order) return {status: ok}4.3 支付通道的选型与容错国内旅游场景主要用支付宝和微信支付。两者各有特点支付宝的PC端和移动端体验都比较成熟适合大额支付微信支付在移动端更顺手适合小额快速支付。我的策略是双通道并行用户自己选。但双通道带来一个问题订单状态需要跨通道统一。同一个订单可能先尝试支付宝失败再换微信支付成功。订单表里需要记录支付通道和对应的交易号状态判断时不能只看一个通道。还有一个坑是支付超时。用户发起支付后可能一直不付订单会一直挂在“待支付”状态。需要设置超时时间比如30分钟超时后自动取消订单并释放库存。超时时间不能太短否则用户还没操作完订单就没了也不能太长否则库存被长期占用。注意支付回调的URL必须是公网可访问的HTTPS地址且不能带任何鉴权参数。支付平台在回调时不会携带用户的登录态服务端只能靠签名和订单号来识别身份。4.4 退款与异常处理旅游订单的退款比普通商品复杂因为可能涉及多个供应商。酒店退款、航班退款、门票退款各有各的规则。我的做法是按供应商拆分退款每个供应商独立处理最后汇总退款结果给用户。退款触发条件通常有两种用户主动申请或者供应商确认失败比如酒店满房。后者属于系统异常需要自动触发退款并通知用户。退款到账时间取决于支付平台支付宝通常实时到账微信支付可能延迟几分钟。异常处理的关键是记录完整的操作日志。每一笔支付、退款、状态变更都要记录时间、操作人、操作结果。出问题时能快速定位是哪一步出了差错。我见过因为没有日志一笔退款查了三天的案例教训深刻。5. 常见问题与排查技巧实录5.1 对话层常见问题问题一流式输出中断用户看到半截回复。原因通常是网络波动或服务端超时。排查时先看服务端日志确认是模型生成超时还是网络传输中断。如果是模型超时需要调整超时时间或优化提示词减少生成长度。如果是网络问题前端需要实现自动重连从断点继续接收。问题二多轮对话中模型“忘记”了之前的约束。比如用户第一轮说了预算一万第三轮模型推荐了超预算的酒店。这是上下文窗口溢出或摘要压缩丢失信息导致的。排查方法是打印每轮实际传给模型的完整提示检查关键约束是否还在。解决方法是把核心约束预算、人数、日期单独提取出来每轮都强制注入系统提示。问题三工具调用参数格式错误。模型传的日期格式和接口预期不一致或者数字传成了字符串。排查时在MCP服务器入口打印收到的原始参数对比接口文档。解决方法是在工具描述里明确格式要求并在MCP服务器做参数校验和自动修正。5.2 MCP层常见问题问题一工具注册了但模型不调用。可能原因有三个工具描述不够清晰模型不知道什么时候该用工具名称和模型已有认知冲突系统提示里没有引导模型使用工具。排查时先看模型是否在回复中提到了相关能力如果提到了但没调用说明描述有问题如果完全没提说明系统提示需要加强引导。问题二工具调用超时。外部接口响应慢导致整个对话卡住。解决方法是在MCP服务器设置合理的超时时间超时后返回明确的错误信息让模型决定是重试还是换方案。同时前端要有加载状态提示避免用户以为系统死了。问题三多个工具返回结果冲突。比如天气工具说下雨路线规划工具推荐了户外景点。这需要Agent层做冲突检测和优先级判断。我的做法是在系统提示里定义优先级规则安全相关天气预警高于体验相关景点推荐实时数据高于缓存数据。5.3 支付层常见问题问题一回调收不到。先检查回调URL是否公网可访问用curl模拟支付平台的请求看能否到达。再看防火墙和负载均衡配置确认没有拦截。如果URL没问题检查支付平台的回调日志看是否发送失败。常见原因是HTTPS证书过期或域名解析异常。问题二订单状态不一致。用户说付了钱但订单还是待支付。先查支付平台的交易记录确认是否真的支付成功。如果成功但本地状态没更新说明回调处理有问题。用主动查询接口补一次状态同步。如果支付平台显示失败但用户说扣了钱通常是银行侧延迟需要等清算完成。问题三重复支付。用户点了两次支付按钮生成了两笔交易。解决方法是在前端做按钮防抖点击后立即禁用在服务端做订单锁同一订单同时只能有一个支付请求在处理。问题类型典型表现排查入口解决方向流式中断回复不完整服务端日志、网络面板重连机制、超时调整上下文丢失约束被遗忘模型输入提示核心约束强制注入工具不调用模型忽略工具工具描述、系统提示优化描述、加强引导回调丢失状态不同步支付平台日志主动查询补偿重复支付两笔交易订单锁、前端防抖幂等控制5.4 几个救命的排查技巧技巧一全链路TraceID。从用户发送消息开始生成一个唯一ID贯穿对话、工具调用、订单、支付全流程。出问题时用这个ID能串起所有日志定位效率提升十倍。技巧二模拟支付环境。开发阶段用支付平台的沙箱环境不要用真实资金测试。沙箱环境能模拟支付成功、失败、超时等各种情况覆盖大部分异常场景。技巧三定期对账。每天定时拉取支付平台的交易记录和本地订单比对。发现不一致的订单及时处理避免问题积累。这个习惯帮我发现了好几次回调丢失的情况。技巧四降级方案。支付通道故障时要有备用通道。支付宝挂了切微信微信挂了切支付宝。如果都挂了至少要让用户能提交订单后续人工处理。完全不可用比降级体验更糟糕。这套系统上线跑了半年多处理了上千笔订单整体稳定。回过头看最关键的设计决策是分层解耦和幂等处理。分层让每个模块能独立演进幂等让分布式环境下的状态最终一致。如果你也在做类似的项目建议先把这两点想清楚再动手能省很多返工的时间。