
一个人的全栈项目:AI 行程规划 + 攻略分享微信小程序。前端是微信原生小程序 + TDesign,后端 Spring Boot,行程由大模型生成,地图靠腾讯位置服务绘制。这篇文章不讲架构图有多漂亮------讲那些技术文档里永远不会写的事。
〇、先讲一个翻车现场
先看一个真实案例。
测试攻略导入功能时,我导入了一篇喀什---帕米尔高原线的游记。地图渲染出来,「红其拉甫国门」这个节点的定位标记,实际指向的是------
「红其拉甫国门公共厕所」。
红其拉甫是中巴边境的国门,海拔四千七百多米,游客包车几个小时盘山上去,就为看一眼 7 号界碑。如果用户信了这个定位,下车后会发现自己在厕所门口,而国门还在几公里外。在高原上,这几公里走起来可一点也不轻松。
这不是传统意义的 bug,而是大模型 + POI 搜索联手奉献的一次「双重幻觉」:大模型只负责说出节点名字------「红其拉甫国门」,这没错;坐标则要拿这个名字去地图服务里搜索,而国门口在地图 POI 库里最显眼的建筑设施,还真就是那间厕所。
做这个项目的那段时间,我的深夜基本都献给了这类问题。今天这篇文,就把整个开发过程中最值得讲的部分摊开来聊聊。
一、这是个什么小程序
先花一分钟介绍产品本身,方便后面展开。
一句话:你告诉它去哪、玩几天、怎么出行,它给你一份逐日到点位的时间轴行程,画在地图上,可编辑、可分享。
核心是三个能力闭环:
- AI 规划 ------ 填一个表单(目的地、天数、公共交通/自驾、偏好),大模型生成一份完整的逐日攻略,每个节点带时段、预估耗时、备注
- 手动规划 ------ 在地图上增删节点、编辑明细、路线优化,和 AI 生成的行程无缝混编
- 攻略分享 ------ UGC 投稿 → 平台审核 → 攻略库 → 公开分享页,全链路可管可控
技术栈很简单,也很「个人开发者」:
| 端 | 选型 |
|---|---|
| 小程序前端 | 微信原生框架 + TDesign 组件库 |
| 后端 | Spring Boot 2.3 / Java 8 / MyBatis-Plus |
| 大模型 | 阿里云百炼 DashScope,qwen 系列,流式输出 |
| 地图 | 腾讯位置服务(POI 搜索 + 路线规划) |
| 内容生产 | Web 管理端:粘贴游记原文 → 大模型结构化提取 → 入库攻略库 |
一个人,前后端加管理端,没有代码评审,没有产品经理,所有决定自己拍板------这意味着踩坑的收益和代价都归自己,印象格外深刻。
二、行程生成:提示词是喂出来的,不是写出来的
后端的核心是一个行程生成服务。你可能会觉得,接个大模型 API,写个 prompt,不就完了?
我现在的提示词,注释里写着 v6。
第六版。而且这还是「精简版」。前五版分别死于:输出夹带 markdown 代码块标记导致 JSON 解析失败、生成的行程像旅行社宣传册、酒店推荐字段浪费大量 token 却没人看、以及某次模型兴起给「上午」这个字段输出了「清晨,当第一缕阳光洒向古城......」。
几个用血泪换来的实战结论:
1. 结构化输出要用「强约束」,不能靠模型自觉。
swift
"# 输出要求(必须严格遵守)\n"
+ "1. 只输出一个合法的 JSON 对象,禁止输出 JSON 之外的任何文字(包括 markdown 代码块标记、注释、解释);\n"
+ "2. 所有键名必须使用下方「JSON结构定义」中的英文键名,不得增删改键名;所有内容值为中文;\n"
+ "3. days 数组长度必须等于 {TOTAL_DAYS},dayNo 从 1 开始连续递增,date 依次对应出行日期;\n"
+ "4. 每日 timeline 节点数量:总天数≤3天时每天5~7个;4~7天时每天4~6个;8天及以上时每天4~6个;\n"
+ "7. place 为景点/点位名称,只写地点本身名称(如「外滩」「丽江古城」),禁止写入动作或体验描述;\n"
注意第 4 条:节点数量要按总天数分段控制 。早期版本不限制数量,模型生成 15 天行程时热情高涨,每天排 8 个点,JSON 长到被截断------所以后来代码里干脆加了硬性上限 MAX_DAYS = 15,注释写的很诚实:超出后 JSON 输出过长易截断,且游玩天数过长本身不合理。
2. 场景不同,提示词就要彻底分家。
公共交通和自驾是两种完全不同的行程逻辑:前者要管换乘和预约,后者要管驾驶负荷、补能和停车。我的做法是维护两套完全独立的提示词模板,角色设定、核心原则、JSON 结构互不混用,只在库表层面共用。别试图用一套 prompt 加个「出行方式」字段来偷懒------模型会温柔地把两种场景的内容揉成一锅粥。
3. 反模板化要写进「价值观」。
swift
"# 核心原则\n"
+ "2. 拒绝模板化、笼统化、网红流水账内容与通用套话;\n"
+ "3. 优先推荐真实本地人老店,拒绝网红割韭菜店铺;\n"
「拒绝网红割韭菜店铺」这行字在提示词里躺了很久。它当然防不住所有软文腔,但配合「remark 用一句通顺的话串联停留/门票/交通/餐饮,禁止分号罗列」这类格式约束,生成内容的「AI 味」肉眼可见地降了一档。
一点个人体会:提示词工程不是文学创作,是测试驱动工程。每一版 prompt 的修改,背后都该有一批具体的失败样本;没有 bad case 就改 prompt,等于闭门造车。
三、POI 定位:和幻觉作战的主战场
回到开头的公共厕所。这部分是这个项目里我付出最多的地方,值得展开讲。
问题的本质:大模型只生成「名字」,不生成坐标。要把节点画上地图,必须拿名字去位置服务搜索。而搜索是一个模糊匹配过程------它不知道你要的是国门,它只知道哪个 POI 跟你的关键词最「相关」。于是大模型的语义模糊,会被搜索服务的相关性排序放大成空间级别的错误。
先看几个真实翻车样本,全部来自当时的错误日志(名字我至今记得):
| 我想定位的 | 搜索实际命中的 | 翻车原因 |
|---|---|---|
| 红其拉甫国门 | 红其拉甫国门公共厕所 | 设施 POI 抢占景点名 |
| 泽普金湖杨 | 新疆金胡杨药业有限公司 | 同名公司蹭了地名 |
| 花盆巴扎 | 疆小花·衣纺(购物类目) | 类目相关挤掉了本体 |
| 喀什古城东门 | 蜜雪冰城(喀什古城东门店) | 括号后缀碰瓷地标名 |
每一行单独看都好笑,连起来看就是一类系统性问题。逐个解决的过程,沉淀成了一套**「候选优选」算法**,核心思路是双维度打分 + 三道拦截:
第一步,标题匹配分。
arduino
/** 标题匹配分:完全相等 3 > 互相包含 2 > 3 字以上公共子串 1 > 无 0(括号内容不参与匹配) */
private int titleScore(String title, String keyword) {
// 去掉括号后缀再匹配:店铺名普遍带「(XX店)」地名后缀,
// 不能因「蜜雪冰城(喀什古城东门店)」括号里含「喀什古城东门」就当作本体命中
String t = title.replaceAll("[((][^()()]*[))]", "").replace(" ", "");
if (t.equals(keyword)) return 3;
if (t.contains(keyword) || keyword.contains(t)) return 2;
for (int i = 0; i + 3 <= keyword.length(); i++) {
if (t.contains(keyword.substring(i, i + 3))) return 1;
}
return 0;
}
那段正则就是为了治蜜雪冰城的病:先剥掉括号后缀,再做匹配。一家奶茶店开在东门旁边,不代表它就是东门。
第二步,设施类 POI 拦截。
arduino
/** 设施类 POI 拦截:候选标题含公共厕所/停车场等设施词而关键词不含时,视为设施 POI 抢占景点名,直接淘汰 */
private boolean isFacilityPoi(String title, String keyword) {
String[] facilityWords = {"公共厕所", "洗手间", "停车场", "服务区", "加油站",
"酒店", "宾馆", "民宿", "客栈", ...};
for (String w : facilityWords) {
if (title.contains(w) && !keyword.contains(w)) return true;
}
return false;
}
公共厕所、停车场、服务区,以及所有住宿类设施------住宿类店铺名常带地标前缀,比如「喀什古城东门轻居酒店」抢「喀什古城东门」,一并拦截。
第三步,行政区守卫------但这里有个反转。
大模型偶尔会把「喀什市」这种行政区名当景点输出,这类名字没有可定位的意义,按理应该拦截。第一版守卫我写得很粗暴:凡是带「市/县/乡/镇/地区/自治州」后缀的一律拦掉。
结果新疆自驾线的攻略全线崩塌------满归镇、蘑菇气镇、恩和俄罗斯民族乡、柴河月亮小镇,这些镇乡级聚落恰恰是自驾路书里最合法的途经、休整、住宿锚点。拦了它们,纯赶路的天就整个空了。
所以最终的守卫规则是分级的:
ini
// 只拦 市/县/地区/自治州 级:镇/乡级聚落是自驾路书的合法途经/休整/住宿锚点,
// 拦了会让纯赶路天整个变空------提示词也只禁城市/县城名作 poiName
String[] adminSuffixes = {"市", "县", "地区", "自治州"};
只拦市县级,放过镇乡级。 提示词层面同样禁城市/县城名作节点名,代码层面再兜底一次------prompt 约束 + 代码守卫的双保险,是我从这个项目里提炼出的最重要的一条大模型应用心法:
提示词管「大概率」,代码管「必不然」。任何关键约束,都别只押注在 prompt 上。
最后还有一道录用门槛:打分再高的候选,如果标题和关键词之间完全没有字面关联 (纯靠类目蹭上来的,比如那个衣纺店),也不录用------宁可不定位、走兜底逻辑,也不能定错。在地图上,一个错误的坐标比没有坐标有害得多:没有坐标用户会自己找,错误坐标用户会照着走。
四、地图渲染:把工程问题做成美学问题
幻觉治好了,接下来是怎么画。行程地图要表达的信息其实相当复杂:多天行程、每天若干节点、节点间的真实路线、交通方式、当前选中项------全叠在一张图上。
我的解法是抄交通地图的作业,核心是 casing(白描边)+ 分层编码:
javascript
// 白描边(casing):白色实线垫在彩色主芯之下(宽 = 主芯 + 4,每边露出 2px 环带),
// 浅色底图上托起主芯、多天交叉时互相干净切断------公交图同款画法
function buildCasing(points, coreWidth) {
return {
points: points.map((p) => ({ latitude: p[0], longitude: p[1] })),
color: '#FFFFFF',
width: coreWidth + 4,
dottedLine: false,
arrowLine: false,
}
}
设计原则只有两条,但每条都有明确的语义分工:
颜色只表达「天」。 每天一个主色,8 色轮换(玫粉、青碧、暖橙、靛蓝......),相邻天色相拉开、亮暗交替。而 蓝、黄、红 三种颜色被「语义征用」了------蓝是节点徽标,黄是选中高亮,红是警示和红心------配色板必须避开它们。
交通方式用线型表达,不用颜色。 驾车 = 实线 + 白描边 + 箭头;步行 = 点线(短途步行视觉弱化,Google Maps 同款惯例);跨城/降级 = 灰色实线直连。这是交通地图的行业惯例:颜色这么稀缺的通道,凭什么给「方式」用两次?
这套视觉系统还有个工程上的好处:降级路径天生优雅。路线接口失败或额度受限时,直接两点灰线直连,用户看到的不是「坏了」,而是「这段是直达」------错误被编码成了语义。另外跨天衔接线刻意不调路线接口(跨天动线通常经酒店中转,画真实路网反而误导),一笔灰色点线示意串联,顺手还省了地图服务的调用量。
「额度、缓存、限流、降级」------地图 API 免费额度面前,每个独立开发者都得是半个架构师。
五、凌晨两点的 debugger
有几个 bug 值得单独立传,因为它们教会我一件朴素的事:问题的表象和根因,可能隔着一整个系统。
1. 无限循环的 401
某天联调,服务端日志开始以约 5 秒一次的频率收到 /auth/login 请求,停不下来。小程序端表现为所有请求全部报「登录已失效」。
我对着请求封装代码排查了很久。这段代码的设计其实是对的:
scss
// 401 时自动重新静默登录并重试一次(isRetry 标记防死循环)
if (res.statusCode === 401 || (body && body.code === 401)) {
if (noRelogin || opts.isRetry) {
return reject(new Error('登录已失效'))
}
invalidateToken()
await runRelogin()
request(method, path, { ...opts, isRetry: true }).then(resolve).catch(reject)
}
有重试上限、有静默登录、有防循环标记------逻辑毫无破绽。而真相是:开发者工具的 storage 坏了。token 每次写入都失效,登录永远不成功,但代码以为还能救,于是陷入「登录 → 失败 → 再登录」的循环。重启模拟器、清缓存,世界恢复和平。
从那以后我的排查清单多了一条铁律:遇到诡异的循环怪象,先重启环境,再怀疑代码。工程师总是倾向于相信自己的代码有 bug------大多数时候这很专业,但有时候,这只是拒绝接受「环境才是猪队友」这个现实。
顺带一提,这里还有个错误码设计 的坑:后端的业务异常如果复用 HTTP 200 + code 401 的信封,会被前端统一当成「登录失效」处理,触发循环重登。所以团队约定:业务错误永远不用 401 做错误码。一个数字的约定,背后是一次循环重登的事故。
2. 两个页面的 redirect 回环
攻略详情有两个页面:「时间轴页」(展示 AI 生成的逐日清单)和「地图编辑页」(有坐标节点的全屏地图 + 底部抽屉)。规则是:AI 攻略定位了第一个节点后,自动从时间轴页跳到地图页;反之没有定位节点时,从地图页回到时间轴页。
某天两个页面开始互相 redirect,无限弹跳。
根因很典型:两页各自实现了「是否存在已定位节点」的判断,而两份实现在某个边界条件下结果不一致 ------A 页判断「该跳」,跳过去后 B 页判断「该跳回来」。修法不是打补丁,而是把判断收敛成唯一谓词:
javascript
/**
* 是否存在已定位节点(detail 与 ai-detail 两页的编辑模式互斥判定共用)。
* 回环安全完全依赖两侧谓词同源,勿在页面内另写一份实现。
*/
function hasLocatedNode(days) {
return (days || []).some((d) => (d.nodes || []).some((n) => n.latitude != null))
}
一行函数,注释比代码长。这就是**单一事实来源(Single Source of Truth)**在小程序页面架构里的落地------任何「两处逻辑必须永远一致」的设计,都是在给未来的自己埋雷。
3. 重启即全员下线
最后一个小成本大教训:JWT secret 曾经是每次启动随机生成的。开发期重启后端是家常便饭,而每次重启,所有已签发 token 全部作废,全员被登出。
修法是把它固定进配置文件------就这么简单,但「配置的默认值决定了系统的行为语义」这个认知,是那次之后才真正长进脑子里的。
六、个人主体小程序的合规生存术
接下来这部分和代码无关,但可能是全文最值钱的一节------如果你也想做个人主体的小程序。
我的小程序是个人主体(没有营业执照的那种)。而个人主体在微信的类目体系里,选不了「内容资讯」类目。这意味着:用户产生的内容(UGC)如果直接公开分发,存在审核风险,极端情况下整个小程序可能被下架。
「攻略分享」恰恰是 UGC。怎么办?放弃这个功能吗?
我的答案是:把合规做成架构,而不是碰运气。设计了一条完整的链路:
markdown
私有详情页(仅本人可见,接口按 userId 过滤)
│
▼
投稿(submit)
│
▼
平台审核(review)
│
▼
攻略库 → 公开分享页(以平台名义发布,不校验属主)
关键设计决策有三个:
- 私有与公开物理隔离 。私有详情页的接口永远按 userId 过滤,公开分享页是另一个接口、另一个页面,内容经过审核后以平台名义发布------用户看到的不是「某网友发的帖」,而是「平台收录的攻略」。
- 给未来的自己立法 。我在项目规范里写死了一条红线:任何「让别人看到攻略」的需求,都接到公开分享页上,勿放开私有页的属主校验。因为我清楚,三个月后的某个深夜,当我想快速实现一个「分享给好友」功能时,最省事的做法恰恰是最危险的那条路。把禁令写成文档,让规范替未来的我守门。
- 数据结构天生兼容。私有攻略和公开分享共用一套表,通过状态字段流转,UGC 内容的每一次可见性升级都必须经过审核这一道闸。
这套东西花掉了我不少时间,远比「直接放开可见性」来得慢。但它换来的是:功能完整保留了 UGC 的全部价值,同时把审核风险关进了流程的笼子里。
独立开发不是法外之地。合规成本在架构设计期支付,永远比在整改通知里支付便宜。
七、从 mvn test 到 Web 控制台:工具的进化史
攻略库需要内容,内容从哪来?除了用户投稿,我自己批量导入高质量游记------把网上优秀的长文游记转化成结构化攻略。
这个功能的形态,经历了三个阶段,像一部小型的工程进化史:
阶段一:测试类脚本。 最早它是一个 JUnit 测试类,提示词和防误匹配逻辑都在里面,跑一次导入用 mvn test -Dtest=TravelGuideImportTest -Dguide.import=1。能用,但每次导入都要开 IDE,改参数要改代码,导入几千字的游记和跑单测共用一个入口,怎么看怎么别扭。
阶段二:服务化。 把逻辑搬进正式服务层,做成异步任务 + 轮询进度的接口。导入不再阻塞请求线程,失败可追溯。
阶段三:Web 管理端。 配一个简单的 Vue 管理页:粘贴游记原文 → 点导入 → 大模型提取结构化行程 → 预览干跑(dry-run,只解析不入库)→ 确认入库。全程可视,且和测试脚本共用同一套提示词与防误匹配规则------工具可以换代,规则必须同源,否则线上和离线的行为漂移会让你怀疑人生。
回头看,这个进化路径本身比功能更有价值:先让一件事发生,再让它方便,最后让它可控。很多人(包括以前的我)总想一步到位做管理后台,结果后台做完了,事情本身还没跑通过一次。
八、总结
一些散装但真诚的收获,按含金量排序:
关于大模型应用
- 提示词管大概率,代码管必不然。关键约束永远 prompt + 代码双保险------「禁止输出城市名」写在提示词里,「拦截城市名」写在代码里,缺一不可。
- 在地图上,错误坐标比没有坐标更有害。这条原则决定了整个候选优选算法的保守基调:宁可不定位,不可定错位。推广到所有大模型应用:不确定时,交白卷好过编答案。
- 提示词工程是测试驱动的。v1 到 v6 的每一步,背后都是具体的失败样本。没有 bad case 的 prompt 优化都是行为艺术。
- 模糊性会被下游放大。大模型输出一个模糊的名字,经过搜索服务的相关性排序,变成空间级别的错误。识别链路上每一环的误差放大系数,是大模型应用架构设计的核心功课。
关于工程
- 单一事实来源不是洁癖,是回环 bug 的唯一解。两份「必须永远一致」的逻辑,本质上是一颗定时炸弹。
- 环境先于代码。诡异循环怪象,先重启再 debug。
- 降级路径也要优雅。路线拉不到就画「直达」灰线------错误被编码成语义,用户甚至感知不到这是错误。
关于独立开发
- 合规成本在架构期支付最便宜。个人主体小程序的 UGC,靠的不是运气,是「私有 → 投稿 → 审核 → 公开」的链路设计。
- 先发生,再方便,最后可控。工具的进化不要跳级。
最后说点感受。这个项目最迷人的地方在于,它逼着我同时扮演很多角色:写提示词的时候我是甲方,模型是乙方;做防误匹配的时候我是质检员,模型是不太靠谱的供应商;画地图的时候我是设计师,纠结一条描边 2 个像素的环带;凌晨三点对着循环重登的日志,我是自己唯一的 oncall。
一个人做全栈产品,最奢侈的不是自由,是每一个决定的因果都完整地归属于你------踩的坑是自己的,治好的病也是自己的。
红其拉甫那间公共厕所,现在依然好好地立在国门旁边。只是我的小程序里,再也不会有人被导航到它门口了。
如果这篇文章对你有帮助,欢迎点赞收藏;评论区聊聊你在 AI 应用里踩过的「幻觉」坑------我猜你也有一个公共厕所级别的故事。