1. 从一句标题拆出来的真实需求
国庆黄金周出行这件事,每年都在重复同一个剧本:提前半个月开始刷攻略,收藏夹里躺了三十篇"XX地三日游保姆级教程",真到出发那天还是手忙脚乱。问题不在于信息不够,而在于信息太多、太散、太同质化。一个普通家庭想规划一次五天四晚的跨省旅行,要同时处理交通班次、酒店比价、景点预约、天气变化、老人小孩的体力节奏、临时封路或限流公告,这些变量叠在一起,本质上是一个多约束条件下的动态规划问题。
大模型恰好擅长处理这类"模糊需求进、结构化方案出"的任务。你把"带两个老人一个小孩,不想太赶,预算中等,偏好自然风光"这句话丢给它,它能拆解成每天的行程节奏、景点间的通勤时间、备选方案和风险提示。但光有模型不够——你得让用户在手机上随时能问、随时能改,这就需要一个能扛住高并发推理请求的服务端,和一个足够轻、足够顺手的客户端。
这个项目要解决的核心问题就三个:第一,把大模型的行程规划能力封装成稳定可调用的服务;第二,让这个服务在国庆这种流量洪峰下不崩;第三,把它塞进用户每天都在用的手机系统里,做到"打开就能用"。适合谁来参考?如果你正在做 AI 应用落地、对 MaaS 平台和移动端集成感兴趣,或者单纯想给自己做一个趁手的出行工具,这篇内容里的思路和踩坑记录都能直接用。
我先把整体架构的逻辑讲清楚,再往下拆每个环节的具体做法。
2. 整体架构设计与技术选型逻辑
2.1 为什么是 MaaS 而不是自己搭推理服务
自己部署大模型推理这件事,我试过。买卡、配环境、调并发、做量化、处理显存溢出,一套流程走下来,模型还没上线,人已经先累了。更现实的问题是:国庆期间的流量是平日的几十倍,你按峰值配置资源,平时就是浪费;按平时配置,峰值直接雪崩。
MaaS(Model as a Service)的核心价值就在这里——它把推理资源的弹性伸缩这件事从你手里接走了。你只需要关心"我要调哪个模型、传什么参数、拿什么结果",底层的算力调度、模型加载、并发控制由平台负责。对于行程规划这种典型的"请求量波动极大、单次推理耗时中等"的场景,MaaS 的按需调用模式比自建服务在成本和稳定性上都更划算。
选蓝耘元生代 MaaS 的原因很直接:它提供了标准的 API 接口,模型选择比较灵活,而且对国内开发者来说,网络访问的稳定性是一个不能忽视的实际因素。你在做移动端应用时,接口的响应延迟直接决定用户体验,这一点在选型时权重很高。
2.2 为什么客户端选鸿蒙
鸿蒙在这个项目里的角色是"最后一公里"。用户不会为了规划行程专门下载一个 App,但他们会用手机上的语音助手、卡片服务或者轻量级元服务。鸿蒙的元服务(原子化服务)机制允许你把功能拆成一个个独立可调用的卡片,用户不需要安装完整应用就能使用。
具体到这个项目,我把它做成了一个行程规划卡片:用户在桌面添加卡片后,直接输入出行需求,卡片调用后端 MaaS 接口,返回结构化的行程方案。整个过程不需要打开任何 App,交互路径最短。
另一个考虑是鸿蒙的分布式能力。国庆出行场景下,用户可能在手机上看行程、在平板上看地图、在车机上导航,鸿蒙的跨设备流转机制让这些场景之间的切换比较自然。当然,这个项目里我主要用到了手机端的卡片能力,分布式部分只做了基础适配。
2.3 整体数据流
整个系统的数据流可以拆成四段:
- 用户输入:在鸿蒙卡片中输入自然语言需求,比如"国庆去成都,五天,带父母,不想爬山"。
- 请求封装:客户端把输入加上一些隐式上下文(当前日期、用户历史偏好标签)打包成结构化请求。
- MaaS 推理:后端服务接收请求,调用 MaaS 平台的对话模型,通过精心设计的提示词模板生成行程方案。
- 结果渲染:返回的 Markdown 或 JSON 结构化数据在卡片上渲染成可折叠的日程列表,支持一键复制和分享。
这个链路里最关键的环节是提示词工程和结果结构化。模型输出的稳定性直接决定用户体验——如果每次返回的格式都不一样,前端就没法做统一的渲染。我在提示词里强制要求模型按固定 JSON Schema 输出,同时在服务端做了一层校验和兜底转换。
3. 核心细节解析与实操要点
3.1 提示词模板的设计:让模型稳定输出结构化行程
大模型有个通病:你让它"规划一个行程",它可能给你写一篇散文,也可能给你列一个表格,格式全凭心情。要让它稳定输出可解析的结构,提示词必须做到三件事:角色设定明确、输出格式锁定、边界条件说清。
我用的提示词模板大致是这样的结构:
你是一个专业的行程规划助手。根据用户需求生成行程方案。 用户需求:{user_input} 出行天数:{days} 同行人员:{companions} 要求: 1. 每天安排不超过3个主要景点,景点间通勤时间不超过40分钟 2. 如果同行人员包含老人或儿童,每天步行距离控制在5公里以内 3. 输出必须是严格的JSON格式,不要包含任何其他文字 4. JSON结构如下: { "destination": "目的地", "days": [ { "day": 1, "theme": "当日主题", "spots": [ {"name": "景点名", "duration": "建议时长", "tip": "实用提示"} ], "meals": ["推荐餐厅类型"], "transport": "交通建议" } ], "warnings": ["注意事项"] }这里有几个细节值得展开说。第一,为什么用 JSON 而不是 Markdown?Markdown 对人类友好,但对程序解析不友好。你要从一段 Markdown 里提取"第二天去了哪几个景点",得写正则表达式,而模型的 Markdown 格式每次都可能微调,正则很容易失效。JSON 有明确的键值结构,解析稳定性高一个量级。
第二,为什么要限制每天景点数量?不限制的话,模型会给你塞五六个景点,看起来很充实,实际根本走不完。我实测下来,每天2到3个主要景点是比较合理的节奏,留出足够的缓冲时间应对排队、找路、休息。
第三,warnings 字段的价值。这个字段让模型主动输出风险提示,比如"国庆期间该景点需提前3天预约"、"当日可能有雨,建议备伞"。这些信息对用户的实际价值往往比行程本身还高。
注意:提示词里的 JSON 示例不要写得太复杂,层级超过三层后模型的输出错误率会明显上升。如果确实需要更复杂的结构,建议拆成多次调用,每次生成一部分。
3.2 鸿蒙卡片的开发要点
鸿蒙元服务的卡片开发有一套自己的规范,和传统的 Android Widget 或 iOS Widget 思路不太一样。核心概念是FormAbility——卡片本身是一个独立的 Ability,有自己的生命周期。
开发流程大致是:
- 在 DevEco Studio 中创建元服务项目,选择"卡片"模板。
- 定义卡片的外观(用 ArkTS 的声明式 UI 描述)。
- 实现 FormAbility 的 onCreateForm、onUpdateForm 等生命周期回调。
- 在卡片中通过 HTTP 请求调用后端 MaaS 接口。
这里踩过的坑主要集中在卡片的刷新机制上。鸿蒙卡片默认不支持频繁刷新,系统对刷新频率有限制。行程规划这种场景,用户输入后需要等待模型推理(通常3到10秒),这段时间卡片需要显示加载状态,推理完成后更新内容。
我的做法是:卡片首次创建时显示一个输入框和"开始规划"按钮,用户点击后触发一个后台任务,任务完成后通过formProvider.updateForm主动更新卡片内容。这样避开了卡片自动刷新的频率限制,用户体验也比较流畅。
// 卡片更新核心逻辑示意 import formProvider from '@ohos.app.form.formProvider'; import formBindingData from '@ohos.app.form.formBindingData'; async function updateCardContent(formId: string, planData: object) { let formData = formBindingData.createFormBindingData({ planJson: JSON.stringify(planData), status: 'completed' }); await formProvider.updateForm(formId, formData); }提示:鸿蒙卡片的网络请求需要在 module.json5 中声明网络权限,同时注意卡片进程和主应用进程是隔离的,不能直接共享内存状态,需要通过持久化存储或事件机制传递数据。
3.3 服务端的并发处理与降级策略
国庆期间的流量特征是"脉冲式"的——早上七八点和晚上八九点是两个高峰,中间相对平缓。服务端如果直接透传请求到 MaaS,高峰期会出现大量请求排队,用户等待时间从几秒变成几十秒。
我加了两层处理。第一层是请求队列和限流:服务端维护一个请求队列,超过并发上限的请求进入等待队列,同时给客户端返回"正在排队"的状态,让用户知道系统在工作而不是卡死了。第二层是缓存:对于热门目的地(比如几个传统旅游城市)的常见需求组合,把生成的行程方案缓存起来,相同或相似请求直接返回缓存结果,不走模型推理。
缓存的键怎么设计是个学问。直接用用户输入的原文做键,命中率很低,因为每个人的表达方式都不一样。我的做法是先对输入做一次轻量归一化——提取目的地、天数、同行人员类型这三个核心维度,用这三个维度的组合作为缓存键。比如"成都+5天+带老人"这个组合,很多用户的表述不同但核心需求一致,缓存命中率能到30%左右。
降级策略方面,当 MaaS 接口响应超时或返回错误时,服务端不应该直接把错误抛给用户。我的处理是:如果缓存里有相似目的地的历史方案,返回一个标注了"参考方案"的结果,同时提示用户"当前规划服务繁忙,以下为参考行程,建议稍后重新生成"。
4. 实操过程与核心环节实现
4.1 环境准备与项目初始化
先把手头的工具链理清楚。服务端我用的是 Node.js(也可以用 Python,看个人习惯),客户端是 DevEco Studio。MaaS 平台需要先注册账号、创建应用、拿到 API Key 和接口地址。
服务端项目初始化:
mkdir travel-planner-server && cd travel-planner-server npm init -y npm install express axios dotenv目录结构保持简单:
travel-planner-server/ ├── .env # 存放 API Key 等敏感配置 ├── server.js # 入口 ├── services/ │ ├── maas.js # MaaS 接口封装 │ └── cache.js # 缓存逻辑 ├── prompts/ │ └── itinerary.js # 提示词模板 └── utils/ └── validator.js # 输出校验.env文件里放 MaaS 的配置:
MAAS_API_KEY=your_api_key_here MAAS_BASE_URL=https://api.example-maas.com/v1 MAAS_MODEL=your_chosen_model PORT=3000注意:API Key 绝对不能硬编码在客户端代码里。鸿蒙卡片的所有网络请求都应该经过你自己的服务端中转,由服务端去调用 MaaS。客户端直连 MaaS 会导致 Key 泄露,也会让你的账单失控。
4.2 MaaS 接口封装与调用
封装 MaaS 调用的核心是把"发送请求、处理响应、错误重试"这三件事做扎实。
// services/maas.js const axios = require('axios'); const client = axios.create({ baseURL: process.env.MAAS_BASE_URL, timeout: 30000, headers: { 'Authorization': `Bearer ${process.env.MAAS_API_KEY}`, 'Content-Type': 'application/json' } }); async function generateItinerary(prompt, retries = 2) { for (let i = 0; i <= retries; i++) { try { const response = await client.post('/chat/completions', { model: process.env.MAAS_MODEL, messages: [ { role: 'system', content: '你是一个专业的行程规划助手,只输出JSON格式结果。' }, { role: 'user', content: prompt } ], temperature: 0.7, max_tokens: 2000 }); return response.data.choices[0].message.content; } catch (err) { if (i === retries) throw err; await new Promise(r => setTimeout(r, 1000 * (i + 1))); } } } module.exports = { generateItinerary };这里有几个参数需要解释。temperature 设为 0.7是一个平衡点:太低(比如0.2)会让输出过于保守,每个城市的行程都差不多;太高(比如1.0)会让输出发散,甚至出现不存在的景点。0.7 在创造性和可靠性之间取了一个折中。
max_tokens 设为 2000是基于输出长度的估算。一个五天行程的 JSON 结构,包含每天的景点、餐饮、交通、提示,大概在1200到1800个 token 之间。设2000留了一定余量,又不至于让模型输出过多冗余内容。
重试机制是必须的。MaaS 接口偶尔会因为网络抖动或服务端负载出现超时,重试两次能覆盖绝大多数偶发故障。重试间隔用递增的方式(1秒、2秒),避免短时间内反复冲击已经过载的服务。
4.3 输出校验与兜底转换
模型返回的内容不一定总是合法 JSON。常见的问题包括:JSON 外面包了一层 Markdown 代码块标记、某个字段缺失、字符串里有多余的换行符导致解析失败。
校验层要做的事情:
// utils/validator.js function extractJSON(raw) { // 去掉可能的 markdown 代码块标记 let cleaned = raw.replace(/```json\s*/g, '').replace(/```\s*/g, '').trim(); // 尝试直接解析 try { return JSON.parse(cleaned); } catch (e) { // 尝试提取第一个 { 到最后一个 } 之间的内容 const start = cleaned.indexOf('{'); const end = cleaned.lastIndexOf('}'); if (start !== -1 && end !== -1) { try { return JSON.parse(cleaned.slice(start, end + 1)); } catch (e2) { return null; } } return null; } } function validateItinerary(data) { if (!data || !data.destination || !Array.isArray(data.days)) { return { valid: false, reason: 'missing_core_fields' }; } for (const day of data.days) { if (!day.day || !Array.isArray(day.spots)) { return { valid: false, reason: 'invalid_day_structure' }; } } return { valid: true }; }如果校验不通过,服务端不直接返回错误,而是返回一个"简化版"的行程——只包含目的地和每天的景点名称列表,去掉所有嵌套的提示信息。这样用户至少能看到一个可用的结果,而不是一个报错页面。
4.4 鸿蒙卡片端的完整实现
卡片端的 UI 用 ArkTS 写,核心是一个输入区域和一个结果展示区域。为了控制卡片高度,结果区域默认只显示第一天行程,用户点击"展开"后显示全部。
// 卡片 UI 核心结构示意 @Entry @Component struct TravelCard { @State planData: string = ''; @State isLoading: boolean = false; @State inputText: string = ''; build() { Column() { if (this.isLoading) { LoadingProgress().width(40).height(40) Text('正在规划行程...').fontSize(14) } else if (this.planData) { Text('你的专属行程').fontSize(16).fontWeight(FontWeight.Bold) // 行程内容渲染 Text(this.planData).fontSize(12).maxLines(10) } else { TextInput({ placeholder: '说说你的出行计划' }) .onChange((value) => { this.inputText = value }) Button('开始规划') .onClick(() => { this.startPlanning() }) } } .padding(12) } async startPlanning() { this.isLoading = true; // 调用服务端接口 // 成功后更新 planData,失败后显示错误提示 } }卡片和服务端之间的通信走 HTTPS,请求体里带上用户输入和必要的上下文。服务端返回的 JSON 在卡片端做二次渲染,把结构化的数据转成用户可读的文本。
实操心得:鸿蒙卡片的默认高度有限,内容过多会被截断。我的做法是在卡片上只显示摘要信息(目的地、天数、第一天行程),完整内容通过点击卡片跳转到元服务的详情页查看。这样既保证了卡片的简洁,又不丢失信息。
5. 常见问题与排查技巧实录
5.1 模型输出不稳定怎么办
这是最常见的问题。同一个提示词,有时候返回完美的 JSON,有时候返回一段带解释文字的结果。排查思路按优先级排列:
| 问题现象 | 可能原因 | 解决方向 |
|---|---|---|
| 输出包含解释性文字 | 提示词约束不够强 | 在提示词末尾加"只输出JSON,不要任何其他文字" |
| JSON 格式错误 | 模型对嵌套结构理解偏差 | 简化 JSON 结构,减少嵌套层级 |
| 字段缺失 | max_tokens 不够 | 提高 max_tokens 或精简输出要求 |
| 内容重复 | temperature 过低 | 适当提高 temperature 到 0.7-0.8 |
| 景点不存在 | 模型幻觉 | 在提示词中要求"只推荐真实存在的景点" |
我实测下来,最有效的单一改进是在提示词里给出一个完整的输出示例。模型看到示例后,格式遵循率能从70%左右提升到95%以上。示例不需要很长,一个包含两天行程的完整 JSON 就够了。
5.2 高峰期响应慢的优化路径
国庆期间的响应时间从平时的3秒变成15秒以上,用户流失率会急剧上升。优化路径按投入产出比排序:
第一,加缓存。这是性价比最高的手段。热门目的地的常见需求组合,缓存命中后响应时间降到100毫秒以内。缓存的有效期设为6小时比较合适——太短了命中率低,太长了行程信息可能过时(比如景点临时关闭)。
第二,流式返回。MaaS 接口通常支持流式输出(streaming),模型生成一个 token 就返回一个 token。客户端可以边接收边渲染,用户感知到的等待时间从"完整生成时间"变成"第一个 token 的到达时间",体验提升明显。不过鸿蒙卡片的渲染机制对流式更新的支持有限,这个方案更适合元服务的详情页。
第三,请求合并。如果多个用户在相近时间请求了相似的目的地,可以把这些请求合并成一次模型调用,生成一个通用方案后再做个性化微调。这个方案实现复杂度较高,适合流量规模较大的场景。
5.3 卡片不刷新或显示空白
鸿蒙卡片开发中遇到最多的问题就是"卡片没反应"。排查清单:
- 检查 module.json5 中是否声明了
ohos.permission.INTERNET权限 - 检查 FormAbility 的 onUpdateForm 回调是否被正确触发
- 检查卡片进程的网络请求是否被系统限制(卡片进程的网络能力比主应用弱)
- 检查 formBindingData 的数据是否超过了卡片支持的大小限制(通常不超过1MB)
我遇到过一次卡片显示空白的情况,排查了半天发现是返回的 JSON 数据里有一个字段包含了特殊字符,导致 formBindingData 序列化失败。后来在服务端加了一层字符过滤,把所有非 UTF-8 基本平面的字符都替换掉,问题就解决了。
避坑技巧:卡片开发阶段,建议在 FormAbility 的每个生命周期回调里都加上日志输出,通过 DevEco Studio 的日志窗口观察卡片的实际执行流程。很多问题不是代码写错了,而是某个回调根本没被触发。
5.4 成本控制的几个实操手段
MaaS 按调用量计费,国庆期间如果不管控,账单会很难看。几个有效的控制手段:
设置单用户每日调用上限。在服务端记录每个设备标识的调用次数,超过上限后返回提示而不是继续调用模型。上限设为10次左右比较合理,正常用户一天不会规划超过10次行程。
输入长度限制。用户输入超过200个字符时,先做一次摘要压缩再送给模型。长输入不仅增加 token 消耗,还会降低模型输出的聚焦度。
结果复用。同一个用户在同一天内对同一目的地的重复请求,直接返回上次的结果,不重新调用模型。用户改主意了想重新生成,可以通过一个"重新规划"按钮显式触发。
6. 后续可以继续打磨的方向
这个项目从想法到跑通大概花了两周左右的业余时间,核心功能已经可用,但还有不少可以继续打磨的地方。比如把用户的历史行程偏好持久化下来,下次规划时自动带上"上次你喜欢自然风光类的景点"这样的上下文;比如接入实时的天气和交通数据,让行程方案能根据当天实际情况动态调整;再比如把行程方案导出成日历事件,用户一键导入手机日历后就能收到每个景点的提醒。
我个人在实际操作中的体会是,大模型应用落地的难点从来不在模型本身,而在模型和真实场景之间的那层"适配层"。提示词怎么写、输出怎么校验、异常怎么兜底、体验怎么优化,这些看起来不起眼的工程细节,才是决定一个 AI 应用好不好用的关键。模型能力是上限,工程能力是下限,而下限往往比上限更重要。