dev.to #ai短讯
我在徒步途中强制关闭了安全应用,警报依然发出
这是 Hacktoberfest 开源 AI 挑战赛第一周的参赛作品。作者开发了 TrailWatch,一个基于“告知去向与归期”原则的徒步安全检查计时器。该应用旨在解决传统户外安全建议中依赖人工记忆和注意力的痛点,确保在用户失联时能自动触发警报。
这是 Hacktoberfest 开源 AI 挑战赛第一周“去户外走走(Touch Grass)”的参赛作品。
我做了什么
最古老也最常被忽视的户外安全建议是:告诉别人你去哪儿,以及什么时候回来。问题在于,“别人”必须记得这件事,必须注意到你迟到了,并且知道该怎么做。
TrailWatch 就是那个“别人”。它是一个不会忘记你的徒步签到计时器。
- 出发前,你输入一句话:“独自攀登素贴山的 Monk's Trail,预计下午5点左右返回。”
- 本地运行的 Gemma 将这句话转化为计划:返回时间、折返时间、签到频率,以及基于今晚日落时间和天气预报的建议。
- 你把手机收起来。这正是关键所在。除非收到询问“你还好吗?”的通知,否则你不需要再次使用手机,只需在锁屏界面轻点一下即可回复。
- 如果你错过了签到,TrailWatch 会先提醒你,等待一段宽限期,然后通知你的紧急联系人。首先,它会立即发送一条简单的模板消息。接着,Gemma 会根据行程日志撰写一份情况简报并发送给你。当你恢复信号并点击“我没事”时,你的联系人会收到平安确认。
整个徒步过程的屏幕使用时间大约只有三十秒:出发前输入一句话,途中可能再轻点一次屏幕。
有趣的地方不在于 AI。而在于这一点:运行在普通进程中的安全计时器是一个可能会无声死掉的计时器。笔记本电脑会休眠,服务器会重启,手机的省电模式会杀死你的应用。系统必须在无信号且无人监视的时刻正常工作。因此,我构建了 TrailWatch 以确保计时器不会丢失,然后我努力尝试破坏它。
演示
这是一次真实的运行记录,采用单镜头拍摄。本地 Gemma 规划行程,Temporal 运行工作流,实际的推送通过 ntfy.sh 发出。这里有两个技巧,都在屏幕上展示出来:
- 时钟被压缩了。“行程一分钟”等于 2 秒,所以 40 分钟的步行可以在两分钟内完成。它与实时使用的是相同的代码路径;只是计时器的长度被缩放而已。
- “工作者已死亡”阶段以 4 倍速播放(角落里有标注)。那里没有任何有趣的事情发生,而这正是重点。
旁白由 Piper 提供,这是一个开源的离线文本转语音模型,在同一台笔记本电脑上运行。
- Gemma 启动行程:每 15 分钟签到一次,宽限期 15 分钟。
- 我强行终止了工作者(TerminateProcess,无清理操作)。没有任何程序在运行 TrailWatch 代码。
- 手机视图注意到了这一点并予以说明:你的计时器存储在 Temporal 服务器上,仍在运行。
- 签到截止时间已过。随后宽限期也过了。警报现已逾期,但仍然没有工作者。
- 我重启了工作者。Temporal 重播历史记录,发现两个计时器都已触发,于是提醒徒步者并通知联系人。警报在工作者恢复后约 10 秒发出。
- Gemma 的简报到达,“徒步者”点击“我没事”,联系人收到平安确认。

以下是该次运行在 Temporal UI 中的事件历史。事件 64 是来自手机的“我到家了”信号。事件 68 是因该信号而取消的最后一个持久化计时器。每个 send_notice 的结果都显示推送到 https://ntfy.sh/....

这是 Gemma 3 4B 在早期的一次中断测试运行中为联系人撰写的真实简报,未经编辑:
Ali 从校园后方山脊的单人徒步中逾期未归,原计划于 23:54 返回。Ali 于 23:14 开始徒步,最后一次联系也是在 23:14。其最后已知位置未知。Sam,请立即尝试联系 Ali。如果在 15 分钟内无法联系到 Ali,请联系当地紧急服务部门。
(Ali 和 Sam 是演示用的人物角色。)
代码
syncaimain / trailwatch
一个不会忘记你的徒步签到计时器:本地 Gemma + Temporal 持久化工作流 + ntfy
一个不会忘记你的徒步签到计时器。
用你自己的话告诉它你要去哪里,以及什么时候回来。一个本地的 Gemma 模型会将这些信息转化为一份计划:返回时间、折返时间、签到频率,以及基于日落时间和天气预报的安全提示。然后,你把手机收起来。
每次徒步都是一次 Temporal 工作流。它在持久的计时器上休眠,直到下一次签到到期。如果你没有点击“我没事”,它会先轻推你提醒,等待一段宽限期,然后通知你的紧急联系人:首先发送一条模板消息(即时发送,关键路径上不经过 AI),随后由本地 Gemma 撰写一份情况简报。当你重新上线并点击“确定”后,你的联系人会收到一切正常的信号。
杀死工作进程、重启 Web 应用、在徒步中途重启 Temporal 服务器:计时器依然会触发。
TrailWatch 是一个原型。它并不是……
安全逻辑包含在两个文件中:trailwatch/workflows.py(持久化工作流)和 trailwatch/rules.py(模型无法覆盖的确定性规则)。pytest 运行了 12 个测试。scripts/kill_test.py 可以在你的机器上复现视频中的场景。
我是如何构建它的
技术栈:通过 Ollama 使用 Gemma 3 4B,运行在配备 6 GB RTX 3050 的笔记本电脑上 · Temporal(Python SDK,本地开发服务器)· ntfy 用于带有操作按钮的推送通知 · Open-Meteo 用于日落和天气数据 · FastAPI 和一个 HTML 文件。
phone (网页或 ntfy 锁屏按钮)
│ 开始行程 · 我没事(信号) · +30 分钟(更新) · SOS · 我到家了
▼
FastAPI(无状态) ──► Temporal 服务器 ◄── 工作进程
每次行程对应一个 HikeWorkflow
├─ fetch_conditions Open-Meteo 日落 + 天气
├─ plan_trip 本地 Gemma → 规则进行约束
├─ durable timers 签到 / 宽限期 / 重新警报
├─ send_notice ntfy 推送,重试直到成功送达
└─ write_briefing Gemma 情况简报每次行程对应一个工作流
每次徒步都是一次 Temporal 工作流,而我使用的每一个 Temporal 功能都在承担实际任务:
| Temporal 功能 | 在 TrailWatch 中的作用 |
|---|---|
| 持久化计时器 | 签到截止时间、宽限期以及每 30 分钟的重新警报。它们存储在服务器上,而不是我的进程中。 |
| 信号 | “我没事”、“SOS”、“我到家了”和“取消”,从网页或直接通过 ntfy 的锁屏按钮发送。 |
| 更新 + 验证器 | “+30 分钟”将新的返回时间返回给手机。验证器会拒绝像 +500 分钟这样不合逻辑的值,或总时长超过 6 小时的情况。 |
| 查询 | 网页直接从工作流本身读取实时的行程状态。没有数据库。 |
| 活动重试 | Gemma 冷启动、内存溢出错误和错误的 JSON 会通过退避机制重试,然后回退到正则表达式规划器。推送重试最多持续一小时。 |
核心在于计时器与收件箱的竞争:
async def _wait_until(self, trip_min: float) -> bool:
"""持久化计时器与收件箱竞争。如果先到达签到则返回 True。”"""
if self.inbox:
return True
seconds = (trip_min - self._elapsed()) * self.req.minute_seconds
if seconds <= 0:
return False
try:
await workflow.wait_condition(lambda: bool(self.inbox), timeout=timedelta(seconds=seconds))
return True
except asyncio.TimeoutError:
return False这个 wait_condition 超时就是一个持久化计时器。如果工作进程在等待期间死亡,不会丢失任何状态。计时器会在服务器上触发,下一个恢复的工作进程会从完全相同的那一行代码继续执行。
AI 从不处于警报的关键路径上
Gemma 做两件事:将随意的句子转化为计划,并为联系人撰写简报。它不决定是否发出警报,且第一次警报绝不会等待它:
- 警报本身是模板化的,即时发送。
- 然后,尽力而为地,Gemma 撰写内容丰富的上下文简报。如果 Gemma 不可用,联系人已经拥有了所需的信息。
在我的笔记本电脑上,Gemma 冷启动大约需要 70 秒。这对于在家规划行程是可以接受的,但在警报路径上是不可接受的。
模型提出建议,代码做出决定
Gemma 的计划在启用任何功能之前,都要先经过 rules.py 的审核。模型可以提出各种建议,但它不能:
- 在单人徒步时设置超过 60 分钟的签到间隔,或在高风险路线上设置超过 45 分钟的签到间隔
- 降低日落或风力所暗示的风险等级
- 选择一个导致没有剩余时间走回起点的折返时间
每条规则都有对应的测试,其中大多数规则的存在是因为 Gemma 曾经搞砸过。
三个值得讲述的 Bug
- “在第 1440 分钟折返。”对于一次三小时的徒步,Gemma 3 4B 提议的折返时间是 1440 分钟,即整整一天后。修复方法:折返时间必须落在行程的前 75% 内,否则替换为行程的中点。
- “40 分钟后返回” → 明天 12:39 返回。在 23:00 时,Gemma 将“40 分钟后返回”解析为 expected_return_local:“12:39”,并在同一回复中给出了 duration_min: 40。我的解析器信任了时钟时间,因此徒步者会被报告失踪的时间晚了 13 个小时。我之所以能发现这个问题,是因为终止测试运行打印出了计划。修复方法:当模型的时钟时间和持续时间不一致时,使用较早的那个。误报总比漏报好。
- 我自己的 Bug:停机时间导致警报延迟。我的第一个版本是在发送提醒(nudge)后才开始宽限期。如果工作人员在截止时间前处于宕机状态,它在重启时会发出迟到的提醒,并从那时起开始计算宽限期,导致警报晚了一个完整的宽限期才发出。如果你从错误的时刻开始测量,持久化定时器也无济于事。修复方法:宽限期从错过的截止时间开始计算,而该时间在工作流的历史记录中是固定的,因此停机时间永远不会延迟警报。现在有一个集成测试,让工作人员在两个截止时间段内保持宕机,并检查它在恢复运行的瞬间是否触发了警报。
在一次测试中,Gemma 还将 10% 的降雨概率称为“显著”。这就是为什么提示只是建议,而警告来自代码的原因。
测试
- 时间跳跃:Temporal 的测试服务器能在几秒钟内模拟六小时的无声徒步。它检查联系人是否收到恰好 10 条警报(第 75 分钟一条,之后每 30 分钟一条)以及仅一次 Gemma 简报。
- 工作人员死亡:一个带有压缩时钟的真实开发服务器。工作人员在截止时间和宽限期内被关闭,测试检查其在重启时是否触发警报。
- 规则:真实的 Gemma 错误,作为回归测试保留。
为什么开放创新很重要?
因为这个应用知道你是否独自一人在哪里。行程计划意味着“这个人将在下午 5 点前独自待在这条山脊上,且没有人会在家里”。我不希望这出现在别人的日志中。通过 TrailWatch,模型在我的笔记本电脑上运行,Temporal 是开源且可自托管的,ntfy 也可以自托管,天气数据来自开放数据。安全路径中的任何环节都不依赖于供应商的正常运行时间、定价或服务条款。
因为安全工具不应需要订阅费用。运行 TrailWatch 无需任何费用。一个小规模的开放权重模型就足够了,因为架构将其置于非关键路径之外:它负责规划和解释,而代码和持久化定时器负责做出决策。
因为我可以看到它是如何失败的。在本地运行 Gemma 时,我可以不断测试它,观察它犯下 1440 分钟和 12:39 的错误,并将每个错误转化为一条规则和一项测试。更换模型只需更改一个环境变量:任何 Ollama 模型,或任何兼容 OpenAI 的端点,包括托管版的 Gemma。
封闭 API 可能更好的地方:诚实地说,速度和判断力。一个大型的托管模型大约只需 2 秒就能完成规划,而不是 10–35 秒,而且可能不会建议第二天折返。但针对这个问题,我更愿意选择一个我可以运行、检查和约束的模型,而不是仅仅更聪明的模型。
诚实的限制
- 这是一个原型,并非卫星信标或个人定位信标(PLB)。它需要依赖你的手机来偶尔接收推送通知。信号丢失正是它发出警报的场景,但它无法独自从山谷中呼救。
- 视频使用了压缩时钟。代码路径与实时运行相同,但这仍然只是一个演示。
- ntfy 主题类似于公共服务器上的密码。请使用长随机字符串,或者自行托管 ntfy。我在将应用置于隧道之后添加了一个访问密钥,因此即使 URL 泄露也无法取消你的行程。
- 推送投递采用至少一次交付机制。如果工作进程在推送过程中崩溃,重试可能会发送重复消息。在我进行的测试中,当我终止工作进程时正在传输的那条推送恰好只到达了一次,但我并不保证这一点。
我的代理会话
我使用 AI 编码代理(Claude Code)结对编程构建了 TrailWatch。该会话也是发现上述 bug 的地方:代理编写了终止测试,读取了 Gemma 的真实输出,并指出了其先前代码中的宽限期 bug。
奖项类别
- 最佳时间使用奖:每次徒步旅行都是一个持久的工作流,使用计时器、信号、带有验证器的更新、查询和重试策略,并通过视频和活动历史记录进行硬终止测试以作佐证。
- 最佳 Gemma 使用奖:Gemma 3 4B 通过 Ollama 在本地运行,用于规划行程和撰写情况简报,受限于经过测试的规则。
译文已达到本站中文翻译的字数上限,剩余内容请查看原文。