我构建了一个网页应用来回答关于我自身健康数据的问题,原因令人尴尬地微小。我本来就有一种完美的方式来提问:一个 Claude Code 技能,它查询数据库并对结果进行推理。它运行良好。它也无法在 Claude iOS 上运行,而我想要真正询问“应该担心这个吗”的时刻,我通常站在凌晨 6 点的厨房里,而不是坐在办公桌前。
它就运行在一个 URL 上,背后是仅限一个地址的 Google 登录白名单,伴随着 公开源码 减去数据。它读取我曾在以下文章中提到的 TimescaleDB 数据库:过去一直在变化,并且它以两种刻意不同的方式回答两种不同类型的问题。
(声明:这不是医疗建议,其中的阈值是根据个人情况调整的。如果你复制这些规则,你将得到针对他人身体的建议。)
教练视图是确定性的。蛋白质遵守程度、与体重秤的差距、体重趋势、过度训练、举重停滞、数据新鲜度。每一项都是 lib/signals/ 中的纯函数,针对固定装置进行单元测试,任何地方都不涉及 LLM。判决会追溯到一条规则,而不是一种感觉,而 unknown 是一种与 ok 区分开的第一类结果,这一点很重要,因为此数据集不断产生 unknown。
询问视图是一个 LLM。Claude,服务器端,拥有十三个只读工具。它处理着没有人编写规则的问题:我在深蹲上是否停滞不前,我的赤字实际上有多大,我的 VO2max 在做什么。
有趣的设计工作全部集中在第二种视图上,而且主要是关于我拒绝构建的东西。
将 SQL 交给模型。提供模式、一个只读连接,并让它编写查询。这是每个“与你的数据库聊天”演示所使用的设计,只需一个下午,而且对于这么小的数据库来说,查询成本无关紧要。
我拒绝了它,因为在这些数据中有六个陷阱,而且每个陷阱在这个项目中至少已经在我查看的某个页面上产生过一次错误答案。
energy_balance 高估了赤字约 2.7 倍。 在过去 30 天完整的日子里,它报告平均摄入量为 1,602 大卡,而消耗为 3,216 大卡,每日净额为 −1,614 大卡,这预测每周减重 3.2 磅。同一时间窗口的体重秤显示为 1.2。基础能量是根据体重、身高和年龄的公式估算得出的,而手表测量的活动能量则偏高。两者都是真实数字,它们的 差异 并不是一种测量。
reps: 0 是一次尝试但失败的组,而不是一个缺失的组。
给定 SQL,模型会走进全部六个陷阱,这说明不关乎模型,而全然取决于 schema。这些陷阱对它是不可见的。关于名为 calories 的列,没有任何信息能告诉你今天的值是不完整的。
我可以把所有六个陷阱放入系统提示中,如果我这样做,模型在 大多数时候 会答对。这就是设计的决定因素,因为大多数时候结果更糟。
一个总是错误的工具会在第一天被发现并被丢弃。一个正确率达到九十几 percent 的工具会被信任,随后罕见的错误答案会以与正确答案完全相同的自信格式出现。我无法发现它,因为我提问的根本原因是我本来就不知道答案。
因此,这些陷阱是由工具的形状所限制,而不是由指令决定的:
AND observed_on < today_local() 结束。部分天并不是因为模型记得要排除它而被排除。它不可达。energy_balance 无法在其现实检查不在同一负载中到达的情况下被获取。你无法单独获得这个误导性数字。
提示仍然描述了所有六个陷阱,因为理解 为什么 窗口在该位置结束的模型相比仅仅得到截断数据的模型能给出更好的答案。但提示并不是它们所依赖的东西。如果明天删除提示,答案会变得更糟,但它们不会在这六种特定方式上出错。
也没有写入工具,我所说的 literally 而不是作为已禁用或隐藏在确认背后的工具的简称。没有任何东西需要禁用。程序更改保持在 Liftosaur 中,宏目标保持 MacroFactor 的调用,因此聊天没有正当理由去修改任何东西。原因见 ADR 0006。
然后我差点发布了一个工具,而该工具的错误方式是该结构无法防范的。
聊天需要知道哪些指标存在。有一个 metric_catalog 表,保存规范单位和每个指标的关注度,因此 list_metrics 显然会读取该表。它编译通过。它通过了类型检查。它返回的行看起来完全合理。
in_data catalogued uncatalogued
81 38 43
目录覆盖了 81 个指标中的 38 个。其余的 43 个不是过时的垃圾:按覆盖率排序,它们从步行和跑步距离(今天已更新,共 3,865 天)开始,接着是爬升的楼层数、步行心率平均值、步行速度、步长,以及完整的微量营养素面板,所有这些都是最新的,因为 MacroFactor 记录了微量营养素。
因此,该工具将会按照原样工作,并让聊天回答“我没有那个”,关于数据库中恰好存在的十年步行距离。
没有任何测试会发现这一点。该函数按其所说的执行了。我通过在真实数据库上运行一个临时脚本并打印 .length 来发现这一点,注意到它是 38,而我期望的是 81。
修复方法是反转连接。从 observations_daily 开始,对目录进行 LEFT JOIN,未登录的指标将以 catalogued: false 和空单位出现,而不是不显示。每次调用都会对整个连续聚合进行一次聚合操作,EXPLAIN ANALYZE 表明其开销舒适地低于 100 毫秒,对于每次对话只调用一次的情况来说这是可以接受的。
总体形状:除非有某种机制强制执行,否则查找表并不是现实的索引。 没有做到这一点。现在也没有做到这一点,但至少在输出中可以看到缺失。
视图中的原始行如下所示:
observed_on calories protein_g
2026-08-08 1370.9033333354562 187.9353333412348
求和浮点数会这样。一旦进入语言模型就会出现两个问题。这是虚假的精度,因为一个报告精度到五分之一磅的秤实际上没有测量到十三位小数,而引用它们的答案暗示了数据不具备的严谨性。此外,这是纯粹的 token 成本:每个序列的每个点上,十八个字符本可以用六个字符完成。
在工具边界而非查询层将数值四舍五入到两位小数,基于这样的原则:数据层应返回数据库中保存的内容,而工具层本就负责为模型塑造输出。这一操作放在每个工具返回都会经过的单个 json() 辅助函数内部,以确保新工具不会遗忘。对于七天窗口,这使得 get_nutrition 的有效载荷大约减少了三分之一;在九十天序列中,比例减少更多。
我想谨慎一些,不要过度夸大结构性论点,因为模型反复表现出比其所被要求的结构更好的结果。
九个故设陷阱的问题,通过 一个专用的探测脚本 运行,以便结果是你可以重现的,而不是我凭记忆给出的。其中有三个返回的推理过程并不在提示中出现。
当被问到我的 VO2max 在做什么时,它报告了上升趋势,然后未被提示地警告说,Apple 根据户外走路和跑步的心率数据得出该数值,并且“该估计会受到体重下降以及健康状况的影响”,于是得出了“暗示性而非结论性”的结论,而不是宣称有健康提升。提示中根本没有提到 VO2max。
当被问到我在深蹲上是否在拖延时,它通过检查重量和组数将 T1 工作与 T2 工作区分开来。代码库中其他地方存在的层次提示并未暴露给任何工具。
至于睡眠,它在回答前先读取了覆盖率数据,并拒绝对 21 晚的数据进行评分。该答案还包含一个完全是我的错误,我直到一个小时后才发现。这是这篇文章的最后一节。
那就是我最喜欢的那一点,因为这是一次意外。我添加了一个 days365 字段,以便让度量指数在覆盖率方面诚实(基于上述原因)。模型将其用作一个通用的“此处是否有足够数据来回答此问题”的门禁。一个因某种原因而添加的字段被用于更好的目的,这在你基于 token 成本修剪工具有效载荷之前值得知道。
结构限制了失败模式。它不会限制上升空间。
显而易见的下一个功能是跨域:糟糕的睡眠是否能预测错过的举重,深度赤字是否会在恢复标志中显现。应用中的每个信号只读取一个域,其中一些包含指向代码从未实际检查的关系的建议文本。
在编写任何内容之前,我检查了这三个最明显的假设在真实历史中是否成立。它们没有得到真正的测试,而原因正是这一发现。
停滞规则唯一标记的举重是它本应忽略的那个。在 2,545 组和 190 次训练中未经过滤地运行 stalling(),恰好返回一次命中:三头肌下压,重量固定在 47.5 磅,持续两次训练。这是一个 T3 辅助动作,而在同一重量下的 T3 停放是程序按设计运行,而不是停滞。这正是层级过滤器存在的目的——抑制这种假阳性;启用过滤器后就没有剩余内容了。
恢复规则在九年内只触发过一次。我没有在 SQL 中近似其基础数学,而是在历史的每一天上运行了真实的 overreaching() 函数,因为手动让规则产生细微错误正是这个项目一直在抓住我的地方。在 2,409 天中:ok 出现了 2,344 次,unknown 出现了 48 次,watch 出现了 16 次,而 act 只出现了一次,时间是 2022 年 4 月 25 日。自 2024 年 6 月以来,没有任何结果超过了 ok。
最后一段是更正。我的笔记曾说 act 从未触发过,我相信了数月,因为生成该说法的脚本只是一次性的,我从未提交过也没有重新运行过。将其写为 npm run verify-signals 花了十五分钟,立即与笔记相矛盾。让检查可运行,它最终会告诉你一些你不想听到的东西。
并且从 7 月 13 日开始持续记录营养,因此我将用四个完整周来进行相关性分析。这不过是穿着样本外衣的轶事。
因此,该应用中不存在跨域信号,原因是它们所要关联的事件在我数据中尚未出现。我将其归档为带日期的跟进,而不是关闭,因为这些假设并未被否定,只是凭手头数据无法回答。
我觉得这样比直接发布该功能更令人满意。如果我跳过了检查,我的版本会构建四个相关信号,它们永远停留在 unknown,我除了得知页面很嘈杂之外一无所获。
这两个诚实的缺口,因为一个以干净结尾的验证帖子表明它没有足够深入地审视。
询问接口从未在浏览器中渲染。Chrome 扩展无法注入到 localhost,因此流媒体格式仅通过 curl 和解码器上的单元测试进行验证,除此之外别无其他。我在手机上对生产环境使用它时它是有效的,这算是证据但不是测试。
并且 unknown 信号路径尚未在实时数据上进行测试,因为目前没有任何内容是 unknown。告诉模型如何处理它的指令已经经过测试。其行为则不然。
一些数字,因为我在整个系列中一直坚持要它们。今天在九个探测器上的测量结果如下:
# secs ttft turns in out cacheR cacheW cost
1 6.9 6.1 2 705 83 14138 0 $0.0127
3 10.2 6.6 2 3989 299 14138 0 $0.0345
8 13.6 10.1 3 12119 458 21207 0 $0.0826
9 16.8 1.7 2 13344 660 14138 0 $0.0903
缓存的前缀是 7,069 tokens 的系统提示加上十三个工具定义,而 cacheWrite 在第一次之后的每一轮都是零,这是需要观察的数字。如果它曾经停止为零,说明有易变的内容混入了前缀,每一轮都在付出全部代价。一个两轮的问题会读取该前缀两次,因此是 14,138。
九个问题总共花费 37 美分,从 "今天有多少卡路里" 的 1.3 美分到那些拉取一年序列数据的问题的 9 美分。延迟在端到端 6.9 到 16.8 秒之间变化,首次文本到达时间在 1.7 到 10.1 秒之间。这种差异是为什么流式传输和可见的状态行不是一种奢侈:十秒的空白屏幕会被视为卡顿,诚实的修复方法是展示事物正在工作,而不是让它更快。
上面的一切都是我可以命名的缺口。重新运行这些探测发现了一个我无法命名的缺口,它就坐在我刚刚赞美完的答案中。
睡眠响应以 “睡眠覆盖总体约为天数的 ~7%,因此这仅供参考。” 七 percent 是错误的。在过去的三十天里,它是七十 percent,因为我在七月更换了佩戴手表的习惯。
模型并不是在猜测,也没有从数据中读取这一点。它是在从 lib/coach/context.ts 中读取的,我曾在那儿将 "睡眠大约覆盖 7%" 作为一个既定事实写入系统提示,那是几个月前的事,当时这句话还是正确的。
因此,聊天告诉了我关于我自己身体的错误信息,这来源于我在整篇文章中所论证的论点中被豁免的那个组件。工具进行计算。提示进行断言。断言会腐烂,而这个断言已经悄然达到这样的地步:应用程序与其自身的数据库相矛盾。
然后我去删除它,并发现了它为何能够存活。
test('prompt: carries the remaining data traps', () => {
assert.match(prompt, /single weigh-in is noise/)
assert.match(prompt, /7% coverage/) // <- this line
})
陈旧的数字正在接受测试。该断言 要求 它必须存在,因此任何发现问题并修正它的人都会变红,并且相当合理地把它放回去。一个错误的事实获得了拥护者。
在该测试下方的两个测试中,坐落着 prompt: forbids numbers that did not come from a tool。再下方两个测试为 prompt: hardcodes no target values,伴有一个注释,解释目标值每周会变化,因此模型必须去获取它们而不是被告知。该原则已经写下。它已经对一种数字强制执行。而对于另一种数字,一个测试正在维持该违规。
追踪该字符串发现了七个副本,包括数据库中的一条活跃记录以及我在终端运行的教练技能。我当天早上就在 CLAUDE.md 中修复了其中一个,并认为工作已完成。
修复的方法不是更正该数字。而是删除它,让 list_metrics 报告覆盖率,它本来就已经这样做了,这也是模型最初知道要小心睡眠的方式。现在测试断言其逆否:assert.doesNotMatch(buildSystemPrompt(), /\d+(\.\d+)?%\s*coverage/i),因此下一个在那个提示中写入百分比的人会被阻止而不是受到保护。
这正是工具形状胜过提示文本的论点,以错误而非原则的形式出现,在我所决定的系统中这一部分本不需要它。
此集合中的其他两篇:过去一直在变化 是为什么此之下的存储层是仅追加的;以及 我未测量的每个数字都是错误的 是我在撰写这些内容时不得不撤回的五个数字。
——
一个热爱技术的程序员,喜欢分享前沿AI知识和开发经验。