乐于分享
好东西不私藏

3 天搓一个 AI 小程序:从 0 到 1 的技术架构拆解

3 天搓一个 AI 小程序:从 0 到 1 的技术架构拆解

项目背景

这个项目来自之前一个偶然的想法,因为觉得自己经常记不住亲人的生日,想找个地方记录一下,搜了一下微信下面的小程序,普遍都偏老,或者已经停止维护,或者体验不好,或者各种bug。那在现在大模型应用的场景下,是不是我们有更快捷的方式来记录呢?

说干就干,刚开始规划的功能点很单一,就是单纯的语音录入,然后用LLM解析成生日的各个要素。后面的很多想法都是在实践过程中和ai交流得出来的,比如:生成祝福,礼物建议等等。


1. 技术栈选型:不浪漫,只讲理由

1.1 前端:为什么是 uniapp 而不是原生 / Taro

纠结过 3 个方案:

方案
开发速度
多端能力
性能
技术栈熟悉度
结论
微信原生(WXML)
❌ 只能跑微信
✅ 最好
❌ 未来要扩 H5
Taro(React)
✅ 多端
❌ React 不熟
❌ 学习成本
uniapp(Vue 3) ✅ 多端
✅ 熟 ✅ 选它

Vue 3  + Composition API 的写法,和我做管理后台的技术栈复用度 100%,人力复用是独立开发者最稀缺的资源

1.2 后端:为什么是 Spring Boot 3 而不是 Node

很多人建议”个人项目用 Node 更轻”。我尝试过,对我来说反而更慢

  • 定时任务:Spring 的 @Scheduled + cron 表达式开箱即用,Node 要引三方库还要踩时区
  • ORM:MyBatis-Plus 一行 extends ServiceImpl<Mapper, Entity> 就把 CRUD 全写完了
  • 事务:@Transactional 一个注解搞定,Node 的事务管理写起来冗长
  • 线程模型:订阅消息推送是 IO 密集 + CPU 轻量,Java 的线程池 + CompletableFuture 写得爽

再加上 Java 17 的 Record、Text Block,代码简洁度已经和 Kotlin 差不多了。

1.3 完整技术栈

┌─ 前端 ────────────────────────────────────────┐│ uniapp 3.0 + Vue 3.4         ││ Pinia 2 (状态管理)                           ││ uni.request (HTTP)                          ││ 条件编译:#ifdef MP-WEIXIN                    │└───────────────────────────────────────────────┘                    │  HTTPS / JSON┌─ 后端 ────────────────────────────────────────┐│ Spring Boot 3.2.0 + Java 17                   ││ MyBatis-Plus 3.5.5                            ││ Lombok + Hutool                               ││ @Scheduled 定时任务                            ││ RestTemplate / OkHttp 调 LLM                  │└───────────────────────────────────────────────┘                    │┌─ 存储 ────────────────────────────────────────┐│ MySQL 8.0  (主数据)                          ││ Redis 7    (access_token / AI 结果缓存)      │└───────────────────────────────────────────────┘                    │┌─ 外部服务 ────────────────────────────────────┐│ LLM API (DeepSeek / Qwen,OpenAI 兼容接口)  ││ 腾讯云 ASR (语音转文字)                      ││ 微信订阅消息 API                                │└───────────────────────────────────────────────┘

2. 3 天时间表

时间
任务
难点
Day 1 上午
需求 + 数据库设计 + 项目脚手架
表结构一次想清楚,后面很难改
Day 1 下午
日期 CRUD + Tab 首页
Day 1 晚上
接入 LLM,实现”文本 → JSON”
Prompt 工程
Day 2 上午
语音录入(录音 → ASR → 文本 → LLM)
录音格式跨平台兼容
Day 2 下午
阴历 / 阳历转换 + “今年的阳历日”计算
闰月、跨年边界
Day 2 晚上
AI 祝福语(流式输出)
SSE 在小程序里的兼容处理
Day 3 上午
微信订阅消息 + 定时任务
订阅次数累积策略
Day 3 下午
UI 打磨 + 真机调试
iOS 录音权限
Day 3 晚上
提审
类目选 “工具-效率”

真正卡时间的不是功能开发,而是3 个看起来小但其实吃时间的坑,下面重点讲。


3. 核心功能一:LLM 把一句话变成结构化日期

这是整个产品的灵魂。传统录入要让用户填 6 个字段:姓名、日期、阴阳历、类型、提醒天数、备注。6 个字段,用户填到第 3 个就跑了。

我的做法:让用户说一句话

3.1 用户输入示例

输入: "我老婆的生日,农历八月十五,提前一周提醒我"输出(结构化 JSON):{"person""老婆","type"1,               // 1=生日 2=纪念日 3=其他"calendar_type""lunar","lunar_date""2026-08-15","remind_days": [7],"confidence"0.96}

3.2 Prompt 工程:从 70% 到 95% 的三个技巧

初版 Prompt 随便写的,准确率只有 70%,迭代后稳定在 95%,关键做了三件事。

技巧 1:强制 JSON 输出(不要裸跑)

DeepSeek / OpenAI 兼容接口都支持 response_format,别省这一行:Map<StringObject> body = Map.of("model""deepseek-chat","messages", messages,"response_format"Map.of("type""json_object"),"temperature"0.3);

temperature 必须调低(0~0.3),解析类任务不需要创造性。

技巧 2:Few-shot 比长篇大论管用

别在 System Prompt 里写一大堆”你要注意 XXX、不要 YYY”,不如直接给 3 个示例:你是一个日期解析助手。只返回 JSON,不要任何解释。示例 1:输入: "我妈生日是 5 月 20 号"输出: {"person":"妈妈","type":1,"calendar_type":"solar","date":"2026-05-20","remind_days":[1],"confidence":0.95}示例 2:输入: "结婚纪念日 2020 年 10 月 1 日,提前三天提醒"输出: {"event":"结婚纪念日","type":2,"calendar_type":"solar","date":"2020-10-01","remind_days":[3],"confidence":0.98}示例 3:输入: "外婆忌日农历七月初七"输出: {"person":"外婆","event":"忌日","type":3,"calendar_type":"lunar","lunar_date":"2026-07-07","remind_days":[1,7],"confidence":0.92}现在解析: "{用户输入}"

Few-shot 的效果立竿见影——模型对着抄作业是它最擅长的事。

技巧 3:confidence 字段 + 兜底逻辑

不要无脑信任 LLM。让它自己打个置信分,后端根据分数走不同分支:if (parsed.getConfidence() < 0.6) {// 低置信:走关键词正则兜底 + 让用户二次确认return fallbackParseWithRegex(rawText);}if (parsed.getConfidence() < 0.85) {// 中等置信:AI 解析 + 前端弹窗让用户校对return AiResult.ofNeedConfirm(parsed);}// 高置信:直接返回return AiResult.ofSuccess(parsed);

3.3 服务端核心代码(片段)

@Service@RequiredArgsConstructor@Slf4jpublicclassDateParseService {private final LlmClient llmClient;private final StringRedisTemplate redis;privatestatic final StringCACHE_PREFIX = "date:parse:";publicParseResultparse(String rawText, Long userId) {// 1. 结果缓存(同样的话不花第二次钱)String cacheKey = CACHE_PREFIX + DigestUtil.md5Hex(rawText);String cached = redis.opsForValue().get(cacheKey);if (cached != null) {returnJSON.parseObject(cached, ParseResult.class);        }// 2. 构造 Prompt(Few-shot 已内联在 PromptTemplate)String prompt = PromptTemplate.DATE_PARSE.render(Map.of("input", rawText));// 3. 调 LLMString json = llmClient.chatJson(prompt);ParseResult result = JSON.parseObject(json, ParseResult.class);// 4. 兜底校验(日期合法性、confidence 阈值)        result = validateAndEnrich(result, rawText);// 5. 高置信结果缓存 24hif (result.getConfidence() >= 0.85) {            redis.opsForValue().set(cacheKey, JSON.toJSONString(result), Duration.ofDays(1));        }return result;    }}

一个省钱的细节:同样的输入走 Redis 缓存,省 LLM 调用费。对于”我妈生日是 5 月 20 号”这种高频输入,命中率非常可观。


4. 核心功能二:语音录入的 3 个真机坑

用户按住麦克风说话 → 自动解析成日期。整个链路:

wx.getRecorderManager().start()    ↓ 录音(mp3)上传后端(multipart/form-data)    ↓腾讯云 ASR(语音转文字)    ↓复用上面的 LLM 解析    ↓返回结构化结果

听起来简单,实际在真机上每一步都踩了坑。

坑 1:iOS 和安卓默认的录音格式不一样

微信官方文档写 RecorderManager 支持 mp3 / aac / wav,但是:

  • iOS
     默认是 aac
  • 安卓
     默认是 mp3

后端统一用 FFmpeg 处理麻烦,必须在调用时显式指定格式

constrecorder = uni.getRecorderManager()recorder.start({format'mp3',        // 强制 mp3,iOS 安卓一致sampleRate16000,    // ASR 最友好的采样率numberOfChannels1,encodeBitRate48000,frameSize50})

sampleRate: 16000 是关键——ASR 服务大部分原生支持 16kHz,高采样率反而会被降采样浪费流量。

坑 2:iOS 首次必须 authorize,否则静默失败

iOS 对麦克风权限特别严。第一次调用录音前不弹权限请求,会直接静默失败,连 error 回调都不进。正确姿势:

async function startRecord() {// #ifdef MP-WEIXINtry {await uni.authorize({ scope: 'scope.record' })  } catch (e) {// 用户拒绝过,引导去设置页const res = await uni.showModal({      title: '需要麦克风权限',      content: '请在设置中打开麦克风权限'    })if (res.confirm) {      uni.openSetting()    }return  }// #endif  recorder.start({ /* ... */ })}

坑 3:短按(< 300ms)会报 operateRecorder:fail

用户手抖点一下就松开,录音还没真正开始就停了。需要前端做最小录音时长兜底:

let startTime = 0recorder.onStart(() => { startTime = Date.now() })functionstopRecord() {const duration = Date.now() - startTimeif (duration < 500) {    uni.showToast({ title'请按住说话'icon'none' })return  }  recorder.stop()}


5. 核心功能三:微信订阅消息的”次数银行”策略

这是决定产品留存生死的功能

5.1 订阅消息的硬规则

微信订阅消息是一次性的:

  • 用户订阅 1 次 = 服务端只能推送 1 条
  • 用户关闭小程序后,之前累积的订阅次数依然有效
  • 想持续推送,必须反复让用户订阅

5.2 次数累积策略

我的做法是所有关键路径都静默触发订阅弹窗

触发时机
累积次数
用户添加新日期成功后
+3
用户打开”详情页”
+1
用户打开”我的”页面
+1
节日前一天主动弹”是否要祝福提醒”
+3

代码片段(前端):functionrequestSubscribe(count = 1) {const tmplIds = Array(count).fill('your_template_id_xxx')  uni.requestSubscribeMessage({    tmplIds,success(res) => {const accepted = tmplIds.filter(id => res[id] === 'accept').lengthif (accepted > 0) {// 同步到后端:这个用户账户 +accepted 次推送额度        api.addSubscribeQuota({ count: accepted })      }    }  })}

后端维护一张”订阅额度表”:CREATETABLE subscribe_quota (  user_id BIGINTPRIMARY KEY,  remaining INTNOTNULLDEFAULT0,  -- 剩余推送次数  total_accepted INTNOTNULLDEFAULT0,  updated_at BIGINT);

每次推送前 UPDATE ... SET remaining = remaining - 1 WHERE user_id = ? AND remaining > 0 扣减。

5.3 定时任务:每天凌晨扫描@Component@RequiredArgsConstructor@Slf4jpublic class RemindJob {privatefinalDateMapperdateMapper;privatefinalSubscribeQuotaServicequotaService;privatefinalWxMpServicewxMpService;/** 每天凌晨 3 点扫描今日需推送的提醒 */    @Scheduled(cron = "0 0 3 * * ?")publicvoiddailyRemind() {LocalDatetoday = LocalDate.now();// 查询所有"今天需要提醒"的日期(提前 0/1/3/7/30 天都会命中)List<RemindItemitems = dateMapper.selectTodayReminds(today);log.info("今日需推送提醒 {} 条", items.size());// 按用户聚合Map<LongList<RemindItem>> byUser = items.stream().collect(Collectors.groupingBy(RemindItem::getUserId));byUser.forEach((userId, userItems) -> {try {pushToUser(userId, userItems);            } catch (Exception e) {log.error("推送失败 userId={}", userId, e);            }        });    }privatevoidpushToUser(Long userId, List<RemindItem> items) {// 检查额度if (!quotaService.consume(userId)) {log.info("用户 {} 无剩余推送额度", userId);return;        }// 调微信订阅消息接口wxMpService.sendSubscribeMessage(buildTemplate(userId, items));    }}

5.4 模板字符限制的暗坑

微信订阅消息的每个字段上限 20 字符。超了整条推送失败,且不返回错误,只能在微信后台推送记录里看到”失败”。调试这个我浪费了 40 分钟。

兜底代码:privateStringtruncate(String s, int max) {if (s == nullreturn"";return s.length() <= max ? s : s.substring(0, max - 1) + "…";}Map<StringObject> data = Map.of("thing1"Map.of("value"truncate(personOrEvent, 20)),"time2",  Map.of("value"truncate(dateStr, 20)),"thing3"Map.of("value"truncate(note, 20)));


6. 数据库:一张表解决生日/纪念日/倒数日

最初设计时很自然地想拆 3 张表:birthdayanniversarycountdown。但 Day 1 中午我推翻了这个设计——它们的字段重合度 95%,拆表等于写 3 套 CRUD。

最终只有一张核心表:CREATETABLE `date` (  id           BIGINT AUTO_INCREMENT PRIMARY KEY,  user_id      BIGINTNOTNULL,  person       VARCHAR(100) COMMENT '人物(生日场景用)',  event        VARCHAR(100) COMMENT '事件(纪念日/其他场景用)',  `date`       DATENOTNULL COMMENT '阳历日期',  type         TINYINT NOTNULL COMMENT '1=生日 2=纪念日 3=其他',  calendar_type VARCHAR(10NOTNULLDEFAULT'solar' COMMENT 'solar/lunar',  lunar_date   VARCHAR(20) COMMENT '阴历原始字符串,如 2026-08-15',  remind_days  VARCHAR(100) COMMENT '提醒天数,逗号分隔如 "0,3,7"',  `group`      VARCHAR(20)  COMMENT '分组:家人/朋友/同事',  hide_age     TINYINT DEFAULT0,  note         VARCHAR(500),  create_time  BIGINT,  update_time  BIGINT,  deleted      TINYINT DEFAULT0,  KEY idx_user_date (user_id, `date`),  KEY idx_user_type (user_id, type, deleted)) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

两个关键索引

idx_user_date(user_id, date) :

首页 SQL 是”查某用户未来 30 天内的所有日期”

SELECT*FROM `date`WHERE user_id = ?AND `dateBETWEEN CURDATE() AND DATE_ADD(CURDATE(), INTERVAL30DAY)AND deleted =0ORDERBY `dateASC

这个复合索引完美覆盖 WHERE + ORDER BY,EXPLAIN 走 range 扫描。

idx_user_type(user_id, type, deleted) :

为分 Tab 展示(生日/纪念日/倒数日)设计。把 deleted 放在索引尾部,能让 WHERE deleted=0 走索引下推,避免回表。

阴历日期的存储方案

很多人会想”把阴历转成阳历存 date 字段不就行了”。不行:

  • 阴历日期在每一年对应不同的阳历日期
  • 闰月年的阴历”闰四月”需要特殊标记

我的方案:

  • date
     字段永远存”今年对应的阳历日“(供查询用)
  • lunar_date
     存阴历原始字符串(供计算用)
  • 定时任务每年 1 月 1 日凌晨,把所有 calendar_type='lunar' 的记录重算一次 date 字段
@Scheduled(cron = "0 0 1 1 1 ?")  // 每年 1 月 1 日 01:00public void refreshLunarDates() {    List<DateEntity> lunarList = dateMapper.selectLunarList();    for (DateEntity d : lunarList) {        LocalDate solarOfThisYear = LunarUtil.toSolar(d.getLunarDate(), Year.now().getValue());        d.setDate(solarOfThisYear);        dateMapper.updateById(d);    }}

阴历转换用 Hutool 自带的 ChineseDate,够用。复杂场景(闰月)可以用 cn.6tail:lunar-java


7. 复盘:做对的、做错的

做对的 3 件事

  1. 一张表打天下
    :省了 60% 的后端代码,现在加”倒数日”只是多一个 type=4
  2. LLM + 置信度兜底
    :没有把命运完全交给 AI,低置信走正则 + 用户确认
  3. 订阅消息”次数银行”
     :把 7 天留存从 12% 拉到 31%

做错的 3 件事

  1. 第一天没做埋点
    :前两周的用户行为数据全丢了
  2. 语音入口藏得太深
    :引导不够,用户占比只有 23%(预期 50%)
  3. 微信登录晚加了 2 天
    :最初用手机号 + 验证码,流失一大批

8. 写在最后

3 天做一个小程序不是什么了不起的事。真正难的是你愿意把周末”浪费”在一个可能没人用的东西上。

独立开发者的时间不能用”投入产出比”来衡量,只能用”你是不是真的想做”来衡量。

如果这篇文章对你有帮助,点个赞让更多独立开发者看到。有问题欢迎评论区交流。