不让模型直接写 SQL,第一眼看像是给自己找麻烦。只读账号、query 工具,“和数据库聊天”的 demo 一个下午就能跑起来,小库查询成本也无所谓。但那篇文章偏不这么干。原因是:模型能猜出一段 SQL 该长什么样,但不知道这段 SQL 的返回值和现实之间隔着什么。
这个判断值得往下挖。如果只是怕写权限,给个只读账号、禁掉 write 工具就完事。真正的问题不在安全,在语义。数据库不做语义检查。它返回的每一行都自带一个假象:只要 schema 合理,它看起来就是真的。Postgres 不会在返回行里写“这一天的摄入其实只记录了十个小时”,它只会吐一个和平时月均一样正常的小数。接下来发生什么,全看 loop 里把 tool_result 翻译成人话的那一步。
我们先把最朴素的 agent loop 立起来。我写过的 agent 工具链都长这个骨架:
def tool_loop(model, tools, question):
ctx = build_prefix(tools) # system prompt + tool schemas
for _ in range(max_rounds):
msg = model.sample(ctx)
if not msg.tool_calls:
return msg.text
for call in msg.tool_calls:
if call.name not in READ_ONLY_TOOLS:
raise Forbidden(call.name)
ctx.append(tool_result(call))
安全边界在第七行就框死:只能调只读工具。看起来没问题。真正出事的位置不在权限判断,在第九行往回塞结果的那个空当。追一次执行就看见了:问“今天吃了多少”,模型第一轮 sample 出一个 tool_call,调读摄入的只读工具;工具返回一个 1370.9033333354562 这种数字;第二轮模型把这个数字组装成自然语言答案。问题在第二轮。它手里只有上下文,上下文里只有这个数字。它不会去看秤上同期的读数,也不会检查这一天记录到底满不满 24 小时。模型拿到 tool_result,要把它变成人话;这一步它会把数据库吐出来的任何东西都当成真的。不是它不谨慎,是它没有第二条判断链——它不知道这个数字在现实里意味着什么。
文章里六个数据陷阱,最狠的是能量缺口。最近 30 个完整日,平均摄入 1602 kcal,消耗 3216,净差 −1614 kcal/天,按这个数预测每周掉 3.2 磅;秤上同期只掉了 1.2 磅。原因不是数据错,是基础能量本身靠体重身高年龄估,手表给的活动消耗又偏宽,这个“缺口”是两个估计值之差,拿来当真实测量必然夸张。模型读到这种数字不会停在“两个估计相减得到这么多”,它会接着往下推,然后拿报告秤体重时一模一样的自信说“你每周减 3.2 磅”。
这就是他那句话为什么是架构判断,不是态度抱怨:大多数时候正确比总是错误更危险。总是错的东西你第一天就丢了。九十多百分比正确的东西你会开始依赖它,然后它在某个罕见场景里错得和平时一样自信——格式一样,连模板都可能是同一个。低概率错不携带自己的元信息;模型不会说“这个数字我有点拿不准,你最好自己瞄一眼”。危险不在错误率,在错误没有自己的格式。
这个应用后来干的事说穿了就一条:把六个陷阱从提示词里拎出来,在 API 表面上做成不可达。不是“告诉模型有这些坑,你注意”,是改工具的形状。
当天记录不完整,所有窗口查询统一以 observed_on < today_local() 结尾,部分日根本不在可查范围。空缺不是零,就保持空缺行,不做任何 zero fill,没有零可被误读。Apple 会把 Liftosaur 的训练影子复制一份,那就不暴露这个源视图,重复计算在工具面就够不着。energy_balance 单独拿出去必然误导,那就让它只能跟现实校验数据一起返回,没别的入口。
到这里就清楚了:真正防止系统说出“你每周掉三磅肉”的,不是多少句提示词叮嘱“缺失不是零”,是这些错法在 tool loop 里根本制造不出来。模型说不出一个它拿不到的结果。提示词只是塑造采样分布的软约束。它拦不住任何一件事,因为模型不执行提示词,它只按上下文采 token。你在系统提示词里重复一百遍“缺失不是零”,那一百遍也是 token,和“这周掉 3.2 磅”一样可被采样。你在 prompt 里写“缺失不是零”,模型学到的不是一个规则,是一个条件概率:在这些 token 后面出现“缺失可能是零”的概率低一些。概率低不等于零。API 形状会硬拒,提示词只给一个拒答的倾向。
再往下挖,还有更隐蔽的腐化点:工具定义本身。工具定义也在缓存前缀里,而且是一大块纯散文。Nazar 在评论里说得对,没有东西拿数据库校验那些散文,它们会像随手写进系统提示词里的任何数字一样慢慢坏掉。
睡眠覆盖率那事就是这样。系统有次回答“睡眠覆盖约 7% 的天数”,错了,最近三十天实际接近 70%。模型不是猜的,也不是从数据里读出来的——它是从系统提示词里读到“睡眠覆盖率大约 7%”这句话。那是作者几个月前写的,当时是真的,后来换佩戴习惯就腐化了。更绝的是下面有个测试断言这串 7% 必须存在。你发现并改掉这个数字,测试就红;结果很可能不是删掉它,而是把错误数字“改回去”。这个 bug 能被测试守住,是因为测试本身写反了——它在保护一个过时的事实,一个现实一变化就必然错误的字符串。
修复也不是把 7 改成 70,是删掉那个数字,让 list_metrics 去报告覆盖率。工具本来就会报告,而且正是它让模型一开始学会了“睡眠数据不足就别乱评级”。所以这里补一句我认为最成立的做法:提示词里不能出现任何不是从工具返回结果派生的具体数值。修完这件事,作者加的反向断言就是这个——系统提示词不得匹配“百分比 + 覆盖率”这种模式。目的不是让 7 永远正确,是阻止下一个人再把这类数字塞回去。
还有更隐蔽的:查找表不等于现实的索引。他那套 catalog 只覆盖 81 个指标里的 38 个,另外 43 个不是废弃数据,是活数据——十年步行距离、爬楼层数、微量营养素面板都在。函数按定义工作,所有测试都绿。要是不去对真实数据库跑一次打印长度,谁也不会发现:系统在数据库里明明捏着十年的步行距离数据,聊天机器人却会老实告诉你“我没有这个数据”。它没撒谎,它是真被设计成看不见。这种“函数工作正常、现实被悄悄削掉一块”的缺口,没有测试能抓住,因为测试只能验证你定义过的东西,验证不了你漏掉没定义的那部分现实。
这跟工具返回值的形状是同一件事。他把数字四舍五入放进 json() 这个每个工具返回值都经过的辅助函数,而不是在查询层做:数据层返回数据库持有的原值,工具层负责给模型塑造输出。原始行会吐十三位小数,1370.9033333354562 这种。秤才报到五分之一磅,你给模型十三位小数,它会真把这串数字当严谨测量引用进答案。何况十三位比两位多出来的全是 token,纯成本上也该在边界砍掉。
这个应用最核心的架构其实在开头两个视图里就露出来了:coach 是确定性纯函数,不碰模型;ask 才把模型请进来,给它十三个只读工具。SQL 被推到工具边界外,不是因为模型不配碰数据,是因为理解和判断查询结果在现实里是不是真的,要的不是生成 SQL 的能力,是知道每个表里有什么坑。后者不来自模型,来自写工具的人把坑一条条焊死在函数形状上。
写到这里我想起自己以前也给 agent loop 塞过“只能只读”的工具,当时觉得够了。后来有个晚上翻调用日志,看见第八轮把上轮结果里的一个中间字段当成事实接着往下推,就知道错了。读权限给你的是“读取数据”的资格,不是“读懂数据”的资格。中间隔着一层,是工具的 API 设计、返回形状和它对现实的映射关系。
剩下的,你自己往上加一层就能感觉到。先别急着写 UI,先把那个最小 loop 写出来,再接一个只读 SQL 工具,让它回答两个问题:今天吃了多少、最近一个月缺口的真实水平是多少。跑一遍,看它在第几个回合、用哪种自信的表情说出第一个错。我不觉得再强的模型能替你解决这个——它能替你生成,但不能替你想清楚边界该画在哪。
