news 2026/9/20 5:15:18

AI陪伴机器人提醒域建模-t_reminder字段与五种提醒类型

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
AI陪伴机器人提醒域建模-t_reminder字段与五种提醒类型

01-提醒域建模-t_reminder字段与五种提醒类型

聊陪伴机器人,大家第一反应都是"能聊天吗"“会不会讲故事”。但真把一台机器人放进有老人的家里,最先被用起来、也最离不开的功能,往往不是聊天,而是提醒。

道理很简单:聊天解决的是"寂寞",提醒解决的是"忘事"。而忘事这件事,轻则忘喝水忘复诊,重则忘吃药。所以在我这个「AI 伙伴(AI-Partner)」项目里,提醒域(t_reminder)是第一批就被认真设计的数据模型。

这一篇我们把这张表掰开揉碎:它的每个字段为什么存在、五种提醒类型怎么分、"时间"为什么需要两个字段、状态为什么也拆成了两个。看完你大概会发现:一个设计得好的表,本身就是一份需求文档。


一、先想清楚:陪伴机器人的"提醒"和手机闹钟有什么不一样

如果只是想"到点响一声",手机闹钟就够了,用不着机器人。所以提醒功能的设计起点,必须先回答一个问题:它凭什么比闹钟好?

差别有三层:

维度手机闹钟陪伴机器人提醒
触发方式手动设置时间用自然语言说一句"明天八点提醒我吃药"
表达方式滴滴滴用你的称呼、你的语气播报出来:“张阿姨,该吃降压药啦”
上下文知道你是老人还是孩子、知道你昨天情绪不好、知道你血压偏高
失败处理无(响过就算完)理论上可做未接提醒、家属通知(本项目暂未实现)

第一层是"入口"的差异,第二层是"人设"的差异,第三层才是真正的产品护城河。而这三层,最终都要落回同一张表。


二、t_reminder的全字段拆解

先上表结构。下面是这个实体的真实字段定义(项目源码entity/Reminder.java):

字段Java 类型列定义 / 注解默认值说明
idLong@Id @GeneratedValue(IDENTITY)自增主键
userIdLong@Column(nullable=false)必填归属用户,t_user.id
deviceIdString@Column(length=64)可空计划播报的设备编码
typeString@Column(length=32)TYPE_CUSTOM提醒类型,见下节
titleString@Column(nullable=false,length=128)必填提醒标题
contentString@Column(length=512)可空提醒正文
remindTimeLocalDateTime@Column(nullable=false)必填绝对触发时间
cronString@Column(length=64)可空重复表达式
statusString@Column(length=16)STATUS_PENDING业务状态
pushedBooleanfalse是否已推送
createdAtLocalDateTime生命周期回调创建时间
updatedAtLocalDateTime生命周期回调更新时间

配套的索引:

-- 建表脚本索引(项目源码)INDEXidx_reminder_user(user_id),INDEXidx_reminder_time_status(remind_time,status)

两个索引分别服务于两类查询:按人查提醒列表按时扫到期提醒。这个分工非常清晰,说明设计者一开始就想清楚了"这张表会被谁怎么读"。

顺带说一句:t_reminder里没有外键。整个项目的 8 张表都没建外键,表间关系全靠user_id/device_id这类字段做逻辑关联。这不是偷懒,而是一种取舍——我们后面在数据库设计那篇会专门聊它的代价。


三、五种提醒类型:medication/schedule/water/birthday/custom

类型枚举是这么定的(项目源码):

publicstaticfinalStringTYPE_MEDICATION="medication";// 吃药publicstaticfinalStringTYPE_SCHEDULE="schedule";// 日程publicstaticfinalStringTYPE_WATER="water";// 喝水publicstaticfinalStringTYPE_BIRTHDAY="birthday";// 生日publicstaticfinalStringTYPE_CUSTOM="custom";// 自定义

为什么不干脆全用custom加一个自由文本?因为类型不只是个标签,它决定了后面三件事:

类型用户会怎么说典型标题建议的默认重复策略风险等级
medication“提醒我早上七点吃药”“吃降压药”每天 / 按医嘱周期高(漏服有健康风险)
schedule“周三下午三点去复查”“去医院复查”单次
water“每小时提醒我喝水”“喝点水吧”每天多次
birthday“8 月 12 号是我孙子生日”“孙子生日”每年低(但漏了很尴尬)
custom“十点提醒我关火”用户原话单次视内容而定

这张表最右边的"风险等级"是我在做这个项目时逐渐意识到的东西:提醒是有安全边界的。药吃错时间、复诊忘掉,可能直接影响健康;而喝水提醒漏一次,真的没关系。风险高的类型,工程上就应该有更强的保障(多重触达、送达确认、失败重试),而现实是——这三种保障,本项目目前一个都没实现。

所以这里的第一个诚实结论是:类型枚举已经为分级保障留好了位置,但分级逻辑还没写。后文会给出补法。


四、Agent 是怎么决定该存哪个类型的

提醒不是用户在表单里点出来的,而是从一句自然语言里"长"出来的。工具方法的签名长这样(项目源码agent/tools/ReminderTool.java):

@Tool("为用户创建一条提醒(吃药/日程/喝水/生日/自定义),返回创建结果和提醒 ID")publicStringcreateReminder(Stringtitle,Stringcontent,StringremindTimeStr,Stringtype,StringrepeatCron,StringdeviceCode,@ToolMemoryIdLonguserId){try{LongreminderId=reminderService.createFromAgent(userId,title,content,remindTimeStr,type,repeatCron,deviceCode);return"提醒已创建,ID="+reminderId+":"+title+"("+remindTimeStr+")";}catch(Exceptione){return"提醒创建失败:"+e.getMessage();}}

注意@Tool描述里那句"(吃药/日程/喝水/生日/自定义)“——这不是给人看的注释,是给大模型看的选项说明。工具描述的措辞直接决定模型传什么type进来。你写"提醒类型”,它就乱猜;你把候选值列出来,它命中率立刻上来。

另外一个细节值得单独拎出来讲:createReminder是六个工具类、十三个@Tool方法里唯一带 try-catch 的一个。为什么偏偏是它?

因为在所有工具里,创建提醒是最容易失败又最需要给用户反馈的一个:时间解析可能失败(“下个月底”)、必填字段可能缺、数据库可能抽风。其它工具挂了顶多这一轮对话少记一条情绪,提醒创建挂了,老人以为"机器人记下了",其实啥都没存——这是会造成实际后果的。

工程经验在这里可以总结成一句:异常处理要按"失败后果"分配,而不是按代码美观分配。


五、remindTimecron并存:一个必须讲清楚的设计意图

remindTimenullable=falsecron是可空字符串。这个组合的意图是很清楚的:

  • remindTime= 下一次(或唯一一次)该响的时间,绝对时间,扫描器直接比大小;
  • cron= 如果这条提醒要重复,重复规则是什么。

听起来合理,但实际运行起来的真相是(这一点必须如实说):

ReminderScheduler的到期扫描方法findDue,只用了remindTimepushed两个条件,从未读取cron

也就是说:用户说"每天 21 点提醒我泡脚",系统会把cron老老实实存下来,然后这条提醒在当天 21 点响一次,之后……就没有之后了。重复提醒在当前版本是不生效的。

这个 bug 挺典型的,它教给我们一个数据建模的道理:

存下来的字段,如果没有任何代码路径读它,那它不是设计,是占位。字段和消费方必须成对出现,否则三个月后接手的人会以为"这块功能是好的"。

正确的重复提醒建模,常见有三种做法:

方案做法优点缺点
A. 单一推进字段只留next_trigger_time,响过后按cron算下一次并回写表最简单、扫描逻辑不变无法表达"跳过某次"
B. 母版 + 实例t_reminder存规则,t_reminder_instance存每次具体触发支持改期、跳过、送达确认实例表会膨胀,需要清理
C. 纯 cron 表只存cron,由调度框架(Quartz 等)托管复用成熟调度能力与业务状态耦合较松

本项目当前的字段布局其实已经站在方案 A 的门口了——remindTime就是那个next_trigger_time,只是缺了"响过之后按 cron 回写"这最后一步。补法是:在dispatchDueReminders里,推送成功后判断cron是否非空,非空则算出下一次时间回写remindTime并把pushed复位为false


六、statuspushed:为什么状态要存两个

初看会觉得冗余:既然有status,为啥还要一个pushed布尔值?

publicstaticfinalStringSTATUS_PENDING="pending";// 待触发publicstaticfinalStringSTATUS_DONE="done";// 已完成publicstaticfinalStringSTATUS_CANCELLED="cancelled";// 已取消

因为这两个字段回答的是两个不同的问题

字段回答的问题关注方
status这条提醒在业务上还有效吗?用户、Agent(查待办列表时用)
pushed这条提醒投递出去了吗?调度器(扫描到期时用)

举个具体场景你就明白为什么要分开:用户取消了提醒(status=cancelled),但这条记录在数据库里还是存在的;调度器扫描时如果不看status只看remindTime,就会给一条已取消的提醒发播报指令——那就尴尬了,老人会说"我都取消了你还提醒我"。

反过来,如果只有status没有pushed,那你没法区分"还没到点"和"到点了但推送失败了"。而"推送失败"恰恰是提醒功能里最需要处理的情况。

不过现状要说清楚:当前调度扫描的查询条件是status + remindTime<=now + pushed=false,逻辑是对的;但推送失败时只打了一行log.error,没有失败次数字段、没有下次重试时间、没有死信表。所以pushed现在实际只有"否 → 是"单向流转,缺少失败态的出口。

一个更完整的字段设计会是:

pushed boolean 是否已送达 push_attempts int 尝试次数(示意,当前未实现) next_retry_at datetime 下次重试时间(示意,当前未实现) last_error varchar 最后一次失败原因(示意,当前未实现)

七、deviceId为空的时候,到底在谁的嘴里说话

t_reminder.deviceId是可空的。为什么可空?因为用户说"提醒我吃药"的时候,心里根本没想"让哪台设备说"。

那到点的时候听谁的?现状是这样的:调度器会去找该用户状态为在线的设备,取到设备编码后把指令发到server/<设备编码>/cmd。设备在线状态的判断依据是t_device.status = STATUS_ONLINE

这里有两个坑,都是真实存在的:

  1. 状态位不如时间差可靠。t_device里明明存了lastHeartbeatAt字段,但代码里从头到尾没有读过它。设备拔网线不会主动告警"我离线了",它的状态就会永远停在online。于是机器人会一脸认真地对着空气说话,日志上一切正常。正确做法是"心跳超时即离线":now - lastHeartbeatAt > 阈值就判离线。这一条我们在设备域那篇会详细展开。
  2. 一人多设备时存在"在哪个房间说话"的问题。如果用户在客厅和卧室各放了一台,只靠"找一台在线设备"是碰运气的。产品上合理的做法是:优先选最近一次检测到用户活动的那台,其次是用户最后交互的那台。

八、索引与查询:一次到期扫描的完整 SQL 视角

提醒域有两条主要读取路径,对应的派生查询方法(项目源码repository/ReminderRepository.java):

方法用途触发的索引
findByUserIdAndStatusOrderByRemindTimeAsc(userId, status)用户查看待触发提醒(Agent 的listReminders工具也走这里)idx_reminder_user(userId)
findByStatusAndRemindTimeLessThanEqualAndPushedFalse(status, now)调度器扫描到期提醒期望走idx_reminder_time_status(remindTime, status)

第二条查询值得掰扯一下。索引建的是(remind_time, status),而查询条件是:

WHEREstatus=?ANDremind_time<=?ANDpushed=falseORDERBYremind_time

从最左前缀原则看,remind_time打头是有意义的——因为查询里对remind_time用的是范围条件,正好可以吃到排序。而如果把status放最前((status, remind_time)),等值条件先命中、再走范围,理论上过滤更精准,但会失去remind_time的天然有序性,排序就得额外做。

这两种建法都对,取决于数据分布:如果status的取值区分度很低(比如 99% 的数据都是pending),那status放前面几乎没用,(remind_time, status)反而更好。索引顺序不是背口诀,是看数据分布。真实项目里可以两条都建、看执行计划再砍。

至于pushed没有进索引,是因为它区分度太低(布尔值),单独进索引用处不大。真要让这条查询跑到极致,可以做成部分索引/复合索引(status, remind_time, pushed),或者干脆把"已推送"的记录归档到历史表,让热表永远保持小体量——后者对定时扫描类是更彻底的解法。


九、从一句话到一条记录:全链路字段映射

把前面所有字段串起来,看一次真实交互:

用户:“明天早上八点提醒我吃降压药。”

环节产出
大模型判断这是要创建提醒 → 调用ReminderTool.createReminder
模型填参title="吃降压药"content(可为空或补充说明)、remindTimeStr="明天早上八点"type="medication"repeatCron=nulldeviceCode=null
NaturalTimeParserremindTimeStr→ 绝对时间(如次日的 08:00)
ReminderService.createFromAgent落库:userId(由@ToolMemoryId注入)、remindTimetypestatus=pendingpushed=false
返回给模型"提醒已创建,ID=1024:吃降压药(明天早上八点)"
模型生成回复“好嘞,明天早上八点我会提醒您吃降压药,记得别空腹~”

注意最后两行:工具返回的是给模型看的事实(ID + 标题),模型再翻译成给用户听的话。这个"两次翻译"的结构是多用户 Agent 的关键——用户永远不该看到ID=1024这种东西,而模型需要它来做后续的取消、修改。


十、这张表还能立刻改进的五件事

最后给一份可以直接照着做的清单,也是本项目提醒域目前真实的短板:

  1. cron真正生效:推送成功后按cron计算下一次remindTime并复位pushed
  2. 失败要有出口:加push_attempts/next_retry_at/last_error三个字段,用退避策略重试,超过 N 次落死信表。
  3. 送达确认:提醒播报后要求设备回一条ack,超时未确认则升级触达方式(重复播报、家属通知)。
  4. 按类型分级保障medication类走强保障(多次播报 + 未确认升级),water类走轻保障(响一次即可)。类型枚举已经备好,缺的是分支。
  5. 归档冷热分离:把done且超过 30 天的提醒移到历史表,主表只留待触发与近期数据,让每分钟一次的扫描始终跑在小表上。

十一、小结

t_reminder这张表只有 11 个业务字段,但它把陪伴机器人提醒功能的骨架都撑起来了:类型(说什么)、时间(什么时候说)、设备(在哪说)、状态(还说吗)、投递(说到了吗)

也给所有做类似功能的同学提个醒:数据表设计完之后,一定要回头检查每个字段的读取方在哪。像cron这种"存了没人读"的字段,在 Demo 阶段看不出问题,等接了真实用户、被问一句"为什么每天提醒只响一次",才会浮出水面。早发现早补,成本最低。

提醒功能只做了一半的严肃性在于:它会给人"已经记住了"的错觉。对老人来说,这种错觉比没有提醒更危险。所以宁可少承诺几个功能,也要把提醒的送达闭环做扎实。任何健康相关提醒都只是辅助手段,用药与治疗请务必遵照医生意见,并让家属共同把关。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/20 5:14:34

从图灵测试到深度学习:AI进化史与技术突破

1. 从图灵测试到深度学习&#xff1a;人工智能的进化之路1950年&#xff0c;一位英国数学家在论文中提出了一个看似简单的游戏&#xff1a;如果人类通过文字交流无法分辨对话方是人还是机器&#xff0c;那么这台机器就具备了智能。这个被称为"模仿游戏"的思想实验&am…

作者头像 李华
网站建设 2026/9/20 5:14:30

SpringBoot+Vue虚拟物品交易平台设计与实现

1. 项目背景与核心需求在数字化浪潮席卷全球的当下&#xff0c;虚拟物品交易已成为一个不可忽视的市场。从游戏皮肤、道具到数字藏品&#xff0c;这些虚拟资产的价值被越来越多的用户认可。然而&#xff0c;传统的私下交易方式存在诸多问题&#xff1a;交易安全无法保障、缺乏第…

作者头像 李华
网站建设 2026/9/20 5:13:05

屏幕右上角出现FPS N/A?一文搞懂来源、关闭与修复方法

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/20 5:10:26

ROS2 Humble从零跑通第一个节点:Ubuntu 22.04实操避坑指南

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/20 5:08:32

GetQzonehistory 上手指南:5 分钟导出 QQ 空间历史说说

GetQzonehistory 上手指南&#xff1a;5 分钟导出 QQ 空间历史说说 【免费下载链接】GetQzonehistory 获取QQ空间发布的历史说说 项目地址: https://gitcode.com/GitHub_Trending/ge/GetQzonehistory 新手机里翻不到 QQ空间几年的老说说和评论。GetQzonehistory 是纯本地…

作者头像 李华