· 閱讀時間 10 分鐘

我們如何讓便宜的 LLM 可靠地操作 DynamoDB:工具驗證器 + eval

DynoTable 的 AI 助理是跑在你自己的 憑證上,這表示模型由你挑 — 而許多人挑的是便宜的那個。 所以我們不會拿頂尖模型來調校助理。我們的底線是 Amazon Nova Lite,一個每次查詢成本近乎捨入誤差、 而且會以頂尖模型不會的方式失敗的模型。

這整篇文章的重點就在這裡:在某個 情境上 — 一個需要把複合鍵表格當作基底的 join — Nova Lite 得了 0%。我們沒有換模型、沒有加 few-shot 範例、也沒有微調。 我們只重寫了一則驗證器錯誤訊息,明確告訴它該改做什麼。 它就衝上了 100%,在下一步就把那個 join 翻正了。

模型從來不是瓶頸。訊息才是。

這就是那堂課背後的工程故事:環繞在每次工具呼叫外圍的驗證器、 我們刻意_不_驗證的東西,以及仲裁每一項變更的 eval 測試框架。 如果你正在一個預算型模型上建置 — 不論是操作 DynamoDB 還是別的東西 — 其中大部分都能沿用。

為什麼便宜的模型偏偏在 DynamoDB 上失敗

DynamoDB 對小模型是個充滿敵意的目標,因為它的查詢介面_看起來_像 SQL,卻不是。 PartiQL 接受 SELECT 語法,卻不支援 JOIN、 不支援 GROUP BY、不支援 DISTINCT、不支援 LIKE — 而一個從網路上學會 SQL 的模型,會信心滿滿地把它們全都吐出來。

我們實際觀察到的失敗,每一個都來自一次真實的紀錄執行:

  • 掉了 limit 參數。 常常在查詢工具上省略那個可選的列數上限。 後端很樂意把所有東西全數回傳,一份 200 KB 的工具結果落進對話, 而下一個模型輪次就在 context 上限上死掉 — 一個迴圈中途的 ValidationException,還被歸咎到錯的元件上。
  • 不支援的構造。 PartiQL 中的 JOINGROUP BY、聚合函式 — DynamoDB 永遠不會執行的語句,之所以被吐出來,是因為它們在 Postgres 上會是對的。
  • 識別字調包。 給 Nova Lite SELECT * FROM customers 與 「為 Germany 加上一個 WHERE」的指示,它有時會回傳 SELECT * FROM orders WHERE … — 一個使用者從未提到的表格。 該語句完美地通過剖析。沒有任何語法檢查能逮到它。
  • 工具導向錯誤。 被問到一個附加檔案時,Nova Lite 呼叫了列出開啟分頁的工具, 想在分頁裡找檔案 — 它幾乎完全不用工具搜尋機制, 於是任何不是直接可見的東西都等於不存在。

這些都不是假設。每一個都催生了下面一道特定的護欄。

在工具邊界驗證,並把錯誤變成一堂課

單一最重要的架構決策:驗證位於工具的 execute 步驟內,而不在 input 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. — 而 system prompt 把它確立為 _修正語句_而非重試相同輸入的訊號。一則拒絕訊息就是一個教學訊號。 在我們採用 FIX: 慣例之前,同樣的失敗最後都以模型道歉、 並叫使用者自己去點匯出按鈕收場。之後,它會自我修正並完成任務。

那個必然的推論則花了更久才學會:具體範例該擺在哪裡,是一個設計軸線。 如果一個錯誤是被某個驗證器在執行期逮到的,就讓 system prompt 保持通用, 並讓動態的錯誤訊息去攜帶具體的表格與修正方式 — prompt 維持精簡,而那堂課會恰好在需要時抵達。 唯有_任何驗證器都逮不到_的錯誤(例如一開始就把一個聚合問題導向正確的工具), 才值得在 prompt 本身放一個具體範例。

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 不支援的構造剖析成一棵有效的語法樹 — 就只為了讓一個 walker 能發出一則具體、 可教的診斷訊息。全都逐字、全都面向模型:

  • 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. — 其中的 替代方案會依函式逐一解析出來。

每一則訊息都指名那個構造_以及_那個逃生口。搭配 prompt 規則 「如果 PartiQL 拒絕了一個不支援的構造,就立刻切換到 SQL workbench」, 一個便宜的模型只需一步就能從它的 Postgres 直覺中恢復,而不會原地掙扎。

知道什麼_不_該驗證

我們 SQL 驗證器中理解 schema 的那一層,會拿表格與欄位參照去比對應用程式手上已有的真實表格描述。 早期它也會標記出對 DynamoDB 未宣告之屬性的參照 — 這聽起來對,卻恰恰是錯的。 DynamoDB 的 attributeDefinitions 只列出屬性, 所以那道檢查拒絕了 WHERE <non-key> = … — 存在意義上最常見的正當查詢形態。 那道檢查如今是一個永遠不會擋路的警告。

每個驗證器都是一場賭注,賭被拒絕的形態比較可能是錯的、而非對的。 當資料模型無法支撐那道檢查時,就別下這個賭注。

把結果上限當成行為,而不只是安全措施

針對掉了 limit 而爆掉的那個問題,修法不是「把 limit 加進 prompt」(便宜的模型反正還是會掉它)。 查詢工具的結果被硬性設限 — 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.

這些上限刻意遠低於 context 能容納的量。目的是行為上的: _逼模型交給使用者一個真實的表格檢視或一個精確的聚合, 而絕不把列一一列舉進聊天裡。_恢復提示會指名確切的工具, 所以這個上限會在模型正需要時,把導向這件事教給它。同一份封裝在 eval 測試框架中被逐位元組地鏡像複製, 而一個回歸情境針對一個一萬項的表格釘住 truncated: true,讓這個上限永遠不會悄悄消失。

助理不會把列一一列舉進聊天,而是發出一個晶片(chip),你點一下就能把結果當成一個真實的 DynoTable 分頁開啟。
助理不會把列一一列舉進聊天,而是發出一個晶片(chip),你點一下就能把結果當成一個真實的 DynoTable 分頁開啟。

Eval 才是仲裁者

上述沒有一項是靠直覺設計、然後靠信仰保留下來的。每一條 prompt 子句、 驗證器訊息與上限,都受一套 eval 測試套件把關,它會針對一個已植入種子資料的 DynamoDB Local 執行真實的 agent 迴圈 — 真實的工具、真實的查詢執行、 直接匯入而非重新實作的正式版 prompt 建構器與驗證器 — 再由針對工具軌跡、資料庫最終狀態與文字的二元檢查來評分。

eval 逮到、而直覺漏掉的東西:

  • 一次「更乾淨」的 prompt 通用化,讓一個情境從 100% 回歸到 0%。 它讀起來對我們比較好。底線模型不同意。只有 eval 注意到了。
  • 頂尖模型的 prompt 建議對便宜的模型是不折不扣的錯。 已發表的指引說要軟化 CRITICAL/MUST 這類措辭,因為大模型會對它過度反應。 便宜的模型則是_遵從不足_,需要那個強硬的版本。 我們如今會對任何借來的建議做 eval 把關,而不是靠信仰照單全收。
  • 嚴格的工具斷言懲罰了正確的行為。 一個要求模型呼叫 ask 工具來釐清的評分器, 在整條模型梯隊上只通過了 19% — 幾乎每一個「失敗」都是一個已經正確地_用文字_請求釐清的模型。 那個評分器如今兩者皆接受。要評分的是面向使用者的意圖,而不是機制。
  • 模型梯隊掙來了它的收窄。 我們一開始很廣 — Mistral、AI21、GLM、Qwen、 Nova Pro,還有更多 — 然後把它砍到兩級(Nova Lite、Claude Haiku), 理由只有真實執行才會浮現:某個家族每次呼叫貴上約 10 倍, 因為 Bedrock 沒有為它提供 prompt 快取;Nova Pro 在開放式 join 上 幻覺出像 customers.customerName 這種串接起來的屬性名稱。 還有一個家族不得不被直接列入封鎖名單 — Bedrock 統一的 API 會很樂意接受某些模型的工具定義,而這些模型的 adapter 之後卻把函式呼叫吐成_純文字_, 這是 API 中繼資料不會告訴你的事。我們是靠一次付費的 smoke 探測、而非靠規格找到它的。

有兩項紀律讓這套套件保持誠實。範例表格永遠不等於測試表格: prompt 範例使用種子資料中不存在的中性名稱,而 eval 跑在不同的表格上 — 於是一次通過證明的是模型把模式一般化了,而不是背下了我們的範例。 以及$0 的 watch 模式拒絕使用已錄製的 fixture: 重放的模型輸出會綠燈通過那些仰賴真實工具活動的斷言, 所以免費層只驗證接線,而任何宣稱有一個_分數_的東西, 都必須在一個真實模型上花掉真實的 token。

檢查清單,如果你正在打造這類東西

  • 先挑好你的底線模型,並始終對著它做 eval;頂尖模型會藏起你的 bug。
  • 把驗證放進工具的 execute 步驟;回傳迴圈能對其行動的錯誤,永遠別回傳 schema 死路。
  • 把每一則拒絕都寫成 哪裡錯了 + FIX: 確切的下一步動作, 並把使用者實際的表格內插進去。
  • 剖析你打算拒絕的東西,好讓診斷能夠具體。
  • 拿每個驗證器去對照你的資料模型稽核 — 刪掉那些會拒絕正當形態的驗證器。
  • 把工具結果設限在容納量以下,並讓截斷通知把模型導向正確的下一個工具。
  • 把 eval 當成針對真實(本機)後端的真實迴圈來跑,重用正式版的程式碼路徑; 讓範例資料與測試資料互不相交;讓 eval — 而非品味 — 成為每一次 prompt 與訊息變更的仲裁者。

如果你用的是頂尖模型,這些還重要嗎?

沒那麼常,但確實重要。這些驗證器在每一級都是關鍵 — 一個頂尖模型照樣不能在 DynamoDB 上跑 JOIN,而規範式的錯誤只是被更快地消化掉。 在我們自己的執行中,一個_頂尖等級_模型的晶片發出行為, 曾在一種較小模型能處理的 prompt 措辭下掉到 0% — eval 測試框架也逮到了那個。 可靠性工程不是一種小模型稅;小模型只是讓帳單來得更早。

這些跑在哪裡

這一切都內建在 DynoTable 的助理及其 受控管工具目錄中:跑在你自己 Bedrock 憑證上、 理解 schema 的自然語言查詢、 精確的全表聚合,以及永遠只落在一個可審查暫存區的寫入。 外部 agent 透過 MCP server 取得同一套經過驗證的工具組 — 我們如何安全地打造_那個_,則是它自己的故事,從 OAuth 到憑證隔離。

而當你想要的是決定性、而非生成 — 那個你會提交進程式碼的查詢 — 就完全跳過模型: Expression Builder 會親手組出那個確切的請求。

不必透過主控台就能操作 DynamoDB

DynoTable 是一款快速的 DynamoDB 桌面用戶端 — 瀏覽表格、執行 SQL 風格的查詢,並在本機編輯項目。