我们如何让廉价的 LLM 在 DynamoDB 上变得可靠:工具校验器 + eval
DynoTable 的 AI 助手运行在你自己的 凭证上,这意味着由你来挑选模型 —— 而很多人会挑一个便宜的。因此我们不会拿一个前沿模型来调优助手。我们的下限是 Amazon Nova Lite,一个每次查询成本低到可以忽略不计、却会以前沿模型不会有的方式出错的模型。
这是整篇文章的点睛之处:在一个 场景中 —— 一个需要以复合键表作为基表的连接 —— Nova Lite 得了 0%。我们没有更换模型,没有添加少样本示例,没有微调。我们只是重写了一条校验器的错误消息,明确告诉它应该改做什么。它随即升到了 100%,在紧接着的下一步就把连接翻转了过来。
瓶颈从来不是模型,而是那条消息。
这就是那个教训背后的工程故事:环绕每次工具调用的校验器、我们刻意_不_去校验的东西,以及仲裁每一次改动的 eval 测试框架。如果你正在一个预算型模型上构建一个 —— 无论是在 DynamoDB 之上还是其他任何东西之上 —— 其中大部分都能迁移适用。
为什么廉价模型偏偏在 DynamoDB 上栽跟头
对小模型来说,DynamoDB 是一个充满敌意的目标,因为它的查询表面_看起来_像 SQL,实则不是。PartiQL 接受 SELECT 语法,却不支持 JOIN、不支持 GROUP BY、不支持 DISTINCT、不支持 LIKE —— 而一个从互联网上学会 SQL 的模型会自信满满地把它们全都生成出来。
我们实际观察到的失败,每一个都来自一次真实记录的运行:
- 丢掉
limit参数。 经常会省略查询工具上那个可选的行数上限。后端乐呵呵地返回全部内容,一个 200 KB 的工具结果落入对话中,而模型的下一轮就死在了上下文上限上 —— 一个循环中途的ValidationException,却被归咎于错误的组件。 - 不受支持的构造。 PartiQL 中的
JOIN、GROUP BY、聚合 —— 这些是 DynamoDB 永远不会运行的语句,之所以被生成,是因为它们在 Postgres 上是正确的。 - 标识符错位。 Nova Lite 在拿到
SELECT * FROM customers以及“为德国加一个 WHERE”的指令后,有时会返回SELECT * FROM orders WHERE …—— 一个用户从未提到过的表。这条语句能被完美解析。没有任何语法层面的检查能抓住它。 - 工具路由错误。 当被问及一个附加的文件时,Nova Lite 调用了列出打开标签页的工具,想在标签页里找文件 —— 它几乎从不使用工具搜索机制,因此任何不直接可见的东西对它来说都不存在。
这些都不是假设。每一个都催生了下面某一条具体的护栏。
在工具边界处校验,并让错误成为一堂课
最重要的一个架构决定:校验存在于工具的执行步骤内部,而不是在输入 schema 里。 schema 层面的拒绝是一条死路 —— 循环只看到一个类型错误,什么也学不到。一个会运行、检查并返回结构化错误的工具,则给了模型一个正常的工具结果,让它可以在下一步据此作出反应。
而且那条错误是写给模型看的,不是写给人类日志看的。我们导出工具的错误逐字读起来是这样的:
startExport requires exactly one of {tabId} or {sql}.
FIX: call listOpenTabs and pass {tabId}, or pass {sql} with a single SELECT.startExport {sql} must be a read-only single SELECT.
FIX: remove any INSERT/UPDATE/DELETE/DDL and pass one SELECT statement.查询校验失败会带上一个 validation: 前缀 —— validation: parse-error: …、validation: partiql-unsupported: JOIN is not supported by DynamoDB PartiQL. —— 系统提示词将其确立为一个信号,表示应_修正这条语句_而不是用相同的输入重试。一条拒绝消息就是一个教学信号。在我们采用 FIX: 约定之前,同样的失败会以模型道歉并告诉用户自己去点击导出按钮而告终。采用之后,它会自我纠正并完成任务。
由此推出的一条推论花了更长时间才领悟:具体示例放在哪里,本身是一个设计维度。 如果一个错误能被运行时的校验器抓住,那就让系统提示词保持通用,把具体的表和修正方式交给动态错误消息去承载 —— 提示词保持精简,而这堂课恰好在需要时才到来。只有那些_没有任何校验器能抓住_的错误(比如一开始就把一个聚合问题路由到正确的工具),才配在提示词本身里获得一个具体示例。
解析你打算拒绝的那些东西
用一句笼统的 syntax error at offset 27 来拒绝 JOIN,什么也教不会。因此我们手写的 PartiQL 解析器做了一件略显反常的事:它刻意把 DynamoDB 不支持的构造解析成一棵有效的语法树 —— 只为让一个遍历器能发出一条具体、可教学的诊断。全部逐字,全部面向模型:
JOIN is not supported by DynamoDB PartiQL.GROUP BY is not supported by DynamoDB PartiQL.TOP N is not supported. Use the API limit parameter.IN list has 120 items. DynamoDB PartiQL caps IN at 50 values (PK column) or 100 values (non-key column).IS NOT NULL is not supported in DynamoDB PartiQL. Use attribute_exists.lower(path) is not a DynamoDB PartiQL function. Use … instead.—— 其中的 替代方案会按具体函数解析得出。
每条消息都会点明具体的构造_以及_那个逃生出口。配合提示词规则“如果 PartiQL 拒绝了一个不受支持的构造,立即切换到 SQL 工作台”,一个廉价模型只需一步就能从它的 Postgres 本能中恢复,而不至于反复瞎折腾。
知道什么不该校验
我们 SQL 校验器中具备 schema 感知的那一层,会对照应用已经持有的真实表描述来解析表和列的引用。早期它还会标记那些指向 DynamoDB 未声明的属性的引用 —— 这听上去没错,实则大错特错。DynamoDB 的 attributeDefinitions 只列出键属性,因此这项检查会拒绝 WHERE <non-key> = … —— 而这恰恰是现存最常见的合法查询形态。这项检查如今只是一个永不拦截的警告。
每一个校验器都是在赌:被拒绝的那种形态更可能是错的而非对的。当数据模型无法支撑这项检查时,就别下这个赌注。
结果上限是一种行为,而不只是一道安全阀
针对丢失 limit 引发的崩溃,修复方案并不是“把上限加进提示词里”(廉价模型反正都会丢掉它)。查询工具的结果被硬性设了上限 —— 10 行或 5 KB,以先到者为准,且始终至少保留第一行 —— 而那条截断提示本身就是规范性的:
Showing 10 of 4,200. To filter: re-run with WHERE.
To view in UI: emit proposeOpenTable.
To aggregate: use runWorkbenchSql GROUP BY.这些上限被刻意设得远低于上下文所能容纳的量。目标是行为层面的:迫使模型交给用户一个真正的表视图或一个精确的聚合结果,而绝不把行逐条列举进聊天里。 恢复提示会点明确切的工具,因此这个上限会在模型需要的那一刻教会它路由。同样的封装在 eval 测试框架中被逐字节地镜像复制,并且有一个回归场景针对一张一万项的表钉死 truncated: true,因此这个上限永远不会悄无声息地消失。

Eval 才是仲裁者
以上没有一样是靠直觉设计、靠信念保留下来的。每一条提示词子句、每一条校验器消息、每一个上限,都由一个 eval 套件把关,该套件针对一个已填充数据的 DynamoDB Local 运行真实的智能体循环 —— 真实的工具、真实的查询执行,生产环境的提示词构建器和校验器是直接导入的,而不是重新实现的 —— 并通过对工具轨迹、数据库最终状态和文本的二元检查来打分。
eval 抓住了直觉漏掉的东西:
- 一次“更干净”的提示词通用化,让一个场景从 100% 退化到了 0%。 我们读着觉得更顺。下限模型不同意。只有 eval 注意到了。
- 面向前沿模型的提示词建议,对廉价模型而言恰恰是错的。 公开的指导意见说要弱化 CRITICAL/MUST 这类措辞,因为大模型会对其过度反应。廉价模型则是_服从不足_,需要那个强硬的版本。如今我们会用 eval 为任何借来的建议把关,而不是凭信念直接采用。
- 严格的工具断言惩罚了正确的行为。 一个要求模型调用
ask工具来澄清的评分器,在整个模型梯队上只有 19% 通过 —— 而几乎每一个“失败”都是一个已经_在文本里_正确地请求澄清的模型。这个评分器如今两种方式都接受。要评的是面向用户的意图,而不是具体机制。 - 模型梯队的收窄是有道理的。 我们一开始铺得很广 —— Mistral、AI21、GLM、Qwen、Nova Pro 等等 —— 然后基于只有真实运行才会暴露的原因,把它削减到两级(Nova Lite、Claude Haiku):有一个模型家族每次调用的成本约为 10 倍,因为 Bedrock 没有为它提供提示词缓存;Nova Pro 在开放式连接上会幻觉出像
customers.customerName这样拼接起来的属性名。还有一个家族不得不被彻底列入黑名单 —— Bedrock 的统一 API 会乐呵呵地接受某些模型的工具定义,而这些模型的适配器随后却把函数调用当作_纯文本_发出来,这是 API 元数据不会告诉你的事情。我们是用一次付费的冒烟探测发现它的,而不是靠某份规范。
有两条纪律让这个套件保持诚实。示例表绝不等于测试表:提示词示例使用的是种子数据中并不存在的中性名称,而 eval 则在不同的表上运行 —— 因此一次通过证明的是模型泛化了这个模式,而不是它背下了我们的示例。以及 $0 的监视模式拒绝使用录制好的固定装置(fixture):重放的模型输出会让那些依赖真实工具活动的断言绿灯通过,因此免费档位只校验接线是否正确,而任何声称给出_分数_的东西,都必须在一个真实模型上花掉真实的 token。
一份清单,如果你正在构建这样的东西
- 先挑好你的下限模型,并始终对照它做 eval;前沿模型会掩盖你的 bug。
- 把校验放进工具的执行步骤里;返回循环可以据此行动的错误,而绝不是 schema 层面的死路。
- 把每一条拒绝都写成
what's wrong+FIX: exact next action,并把用户真实的表插值进去。 - 解析你打算拒绝的东西,好让诊断能够具体。
- 对照你的数据模型审视每一个校验器 —— 删掉那些会拒绝合法形态的校验器。
- 把工具结果的上限设在容量以下,并让截断提示把模型引导到正确的下一个工具。
- 让 eval 作为真实的循环针对一个真实的(本地)后端运行,复用生产环境的代码路径;让示例数据与测试数据互不相交;让 eval —— 而不是品味 —— 成为每一次提示词与消息改动的仲裁者。
如果你用的是前沿模型,这一切还重要吗?
重要的频率更低,但确实重要。这些校验器在每一个层级都是承重的 —— 前沿模型照样无法在 DynamoDB 上运行 JOIN,而规范性的错误只是会被更快地消化掉。在我们自己的运行中,一个_前沿级_模型的 chip 发出行为曾在一种较小模型都能应付的提示词措辞下跌到了 0% —— 这也被 eval 测试框架抓住了。可靠性工程不是一种只有小模型才交的税;小模型只是让账单来得更早。
这一切在哪里运行
这一切都交付在 DynoTable 的助手及其受门控的工具目录之中:在你自己的 Bedrock 凭证上进行具备 schema 感知的自然语言查询、精确的全表聚合,以及永远只会落入一个可审查暂存区的写入。外部智能体通过 MCP 服务器获得同样经过校验的工具集 —— 我们如何安全地构建_那个_,则是另一个故事了,从 OAuth 一直讲到凭证隔离。
而当你想要的是确定性而非生成 —— 也就是你会提交到代码里的那条查询 —— 那就干脆跳过模型:表达式构建器会手工组装出确切的请求。


