· 10 分钟阅读

我们如何让廉价的 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 中的 JOINGROUP 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: 约定之前,同样的失败会以模型道歉并告诉用户自己去点击导出按钮而告终。采用之后,它会自我纠正并完成任务。

由此推出的一条推论花了更长时间才领悟:具体示例放在哪里,本身是一个设计维度。 如果一个错误能被运行时的校验器抓住,那就让系统提示词保持通用,把具体的表和修正方式交给动态错误消息去承载 —— 提示词保持精简,而这堂课恰好在需要时才到来。只有那些_没有任何校验器能抓住_的错误(比如一开始就把一个聚合问题路由到正确的工具),才配在提示词本身里获得一个具体示例。

DynamoDBTool boundaryModelDynamoDBTool boundaryModelrunPartiQL("SELECT … JOIN …")validation: partiql-unsupported:JOIN is not supported by DynamoDB PartiQL.runWorkbenchSql("SELECT … JOIN …")streamed pages (capped)rows10 rows / 5 KB + "Showing 10 of 4,200…"

解析你打算拒绝的那些东西

用一句笼统的 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,因此这个上限永远不会悄无声息地消失。

助手不会把行逐条列举进聊天,而是发出一个 chip,你点击它就能把结果作为一个真正的 DynoTable 标签页打开。
助手不会把行逐条列举进聊天,而是发出一个 chip,你点击它就能把结果作为一个真正的 DynoTable 标签页打开。

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 一直讲到凭证隔离。

而当你想要的是确定性而非生成 —— 也就是你会提交到代码里的那条查询 —— 那就干脆跳过模型:表达式构建器会手工组装出确切的请求。

无需控制台即可使用 DynamoDB

DynoTable 是面向 DynamoDB 的快速桌面客户端 —— 浏览表、运行 SQL 风格的查询,并在本地编辑项。