iT邦幫忙

2026 iThome 鐵人賽

DAY 24
0

https://ithelp.ithome.com.tw/upload/images/20260824/20161290JwtLOZTtrS.png

避免空表格、空圖與數字竄改。

前幾天我們已經介紹 json-render 的 Spec、Flat Tree 與 Catalog。今天不再重複介紹元件結構,而是往前追問一個更重要的問題:Spec 裡面的數字和資料列,究竟應該由誰產生?

在 Generative UI 專案中,許多初學者常犯一個架構錯誤:把資料庫查出的 100 筆客戶旅遊資料全部塞進 Prompt,叫 LLM「請幫我把這些資料轉成 DataTable 的 JSON Spec」

這種做法在 Demo 時看似沒問題,但一旦上線就會引發四種災難性後果:

  1. 數字幻覺與竄改(Data Tampering):原本資料庫裡的消費金額是 45,000,LLM 抄著抄著可能幻覺寫成 48,000,在金融與訂單場景直接構成商業詐欺。
  2. 大陣列截斷與空表格(Truncation & Empty Table):當表格有 50 列以上時,LLM 輸出往往超過 Token 限制或在半途中斷,前端 JSON 解析器無法修補破損的陣列,導致使用者只看到一張空白表格。
  3. Token 費用飆升(Cost Explosion):僅僅是為了讓 LLM「複製貼上」本來就存在於資料庫裡的 JSON 陣列,白白浪費了上萬個 Token。
  4. 生成延遲極高(High Latency):讓模型逐字生成 100 筆資料的 JSON 需要耗時 10~20 秒,前端體驗極差。

今天我們將貫徹 Compute-then-Compose(先運算,後組裝):凡是資料列、統計指標與排序結果,先由 SQL、Java 或其他確定性程式算好;LLM 負責洞察與敘事,最後再由 Java 組裝成 json-render 可以呈現的 UI Spec。

這個原則不只適用於 Generative UI。報表、訂單、財務、庫存與任何需要精確數字的 Agent 流程,都應該把「計算事實」和「解釋事實」分開處理。


1. json-render 在這篇文章中負責什麼?

json-render 的工作是接收符合約定的 UI Spec,並把它渲染成畫面;它不是資料庫,也不是計算器。前端可以透過 schema 與 registry 檢查 Spec 結構是否符合契約,但不會替我們判斷「總金額是否算對」。

可以把完整流程想成四個責任區域:

使用者需求
    ↓
Embabel Agent:理解需求、決定要呼叫哪些能力
    ├── SQL / Java:查詢、統計、排序與格式化真實數據
    └── LLM:根據計算結果產生洞察與敘事
    ↓
Java:把數據與敘事組裝成 DashboardSpec
    ↓
json-render:把 UI Spec 渲染成畫面

因此,DataTable.props.rowsBarChart.props.dataMetricCard.props.value 的數值,應該來自程式計算;Summary.props.explanationAlertBanner.props.message 等文字,才適合交給 LLM 產生。

這樣分工的重點不是「LLM 完全不能碰數字」,而是:需要可驗證的數字,不能只依賴 LLM 的文字生成結果。


2. 今天要解決的痛點與核心觀念

痛點背景:讓 LLM 當「資料搬運工」的代價

  • 不該讓語言模型做它不擅長的事:LLM 擅長語義理解、摘要、推理與敘事;但它不是內建可驗證的計算引擎,不能把精確加總、排序與大量資料複製當成可靠來源。
  • 資料真實性(Data Integrity)是不可妥協的底線:儀表板上的「近一年總消費:$143,000」必須有資料庫交易紀錄做背書,絕不允許有 $1$ 元的浮動。

觀念圖解:數據與敘事分離架構(Compute-then-Compose)

https://ithelp.ithome.com.tw/upload/images/20260824/20161290Py2RPTEc7w.png

權責劃分法則

  • SQL / Java 負責DataTable.props.rowsBarChart.props.dataMetricCard.props.value、聚合、分頁、排序與數值格式化。
  • LLM 負責AlertBanner.props.message(風險提示)、Heading.props.textSummary.props.explanation(趨勢洞察)。
  • json-render 負責:接收已組裝好的 UI Spec,依照前端元件契約呈現畫面。

3. Agent Action 鏈條:先計算,再組裝

在 Embabel 中,我們不把所有事情塞在一個 Action 裡,而是分為三階段 Action:

  1. FetchDataAction(Java):從 CRM / DB 查出強型別 TravellerActivity 與計算指標。
  2. AnalyzeInsightAction(LLM):呼叫 Spring AI 生成結構化的 ActivityNarrative(包含摘要、風險、推薦方案)。
  3. AssembleDashboardAction(純 Java):接收前兩者的輸出,透過 DashboardSpecBuilder 組裝成完整的 DashboardSpec

最後才由前端的 json-render 讀取這份 Spec。換句話說,renderer 是流程的呈現端,不是數據的來源端。

1. 讓資料列不經過 LLM

因為 DataTablerows 陣列完全不經過 LLM,因此:

  • 即使表格有 1,000 筆資料,這些資料列也不會增加 LLM 的輸入或輸出 Token;但 Agent 其他部分的 prompt 與回應仍然會產生 Token。
  • Java 不需要等待模型逐筆生成資料,直接組裝記憶體中的物件;實際速度仍取決於查詢、組裝與傳輸,而不是宣稱固定為零毫秒。
  • 如果再搭配 schema、型別與欄位驗證,就能把資料遺失與 JSON 結構錯誤在進入 renderer 前攔截下來。

2. 防止數據與敘事互相矛盾

在最終組裝前,Java 可以檢查必要的指標、欄位與資料來源是否存在。若 LLM 敘事需要提到數字,應把已計算好的指標以結構化資料提供給它,並對重要數字做回查;發現矛盾時,以可追溯的 Java 計算結果為準,而不是直接相信模型文字。


4. 程式碼示意:在 Agent 邊界組裝數據與敘事

以下實作完整的後端三階 Action 與組裝器。

1. 定義領域物件與敘事結構

package com.antechinus.travel.domain;

import java.util.List;

// 1. 純 Java 資料物件
public record CustomerMetrics(
    Long customerId,
    String customerName,
    double totalSpend,
    int tripCount,
    List<TripRecord> trips
) {
    public record TripRecord(String destination, String date, double amount) {}
}

// 2. LLM 生成的純敘事物件
public record InsightNarrative(
    String headline,
    String executiveSummary,
    String riskWarning
) {}

2. 確定性組裝 Action 實作

package com.antechinus.travel.agent;

import com.antechinus.travel.domain.CustomerMetrics;
import com.antechinus.travel.domain.InsightNarrative;
import com.antechinus.travel.spec.DashboardSpec;
import com.antechinus.travel.spec.DashboardSpecBuilder;
import com.embabel.agent.annotation.AchievesGoal;
import com.embabel.agent.annotation.Action;
import com.embabel.agent.annotation.Cost;
import org.springframework.stereotype.Component;

import java.util.List;
import java.util.Map;

/**
 * 儀表板規格組裝 Action (純 Java 確定性邏輯)
 * 負責將 Java 查出的真實數據與 LLM 生成的敘事無縫合併
 */
@Component
public class DashboardAssemblyAction {

    @AchievesGoal(description = "產出最終可渲染之安全 DashboardSpec")
    @Action
    @Cost(1) // 這是 planner 的成本估計,不代表實際執行時間
    public DashboardSpec assemble(CustomerMetrics metrics, InsightNarrative narrative) {
        
        DashboardSpecBuilder builder = DashboardSpecBuilder.create("root_stack");

        // 1. 根容器
        builder.addElement("root_stack", "Stack", Map.of("direction", "vertical", "gap", 6),
                List.of("elem_heading", "elem_alert", "elem_metrics_row", "elem_trips_table"));

        // 2. 標題與敘事 (來自 LLM)
        builder.addLeaf("elem_heading", "Heading", Map.of(
                "text", narrative.headline(),
                "level", "h2"
        ));

        // 3. 風險預警 (來自 LLM)
        builder.addLeaf("elem_alert", "AlertBanner", Map.of(
                "severity", "info",
                "message", narrative.executiveSummary()
        ));

        // 4. 指標卡片列 (數值 100% 來自 Java metrics)
        builder.addElement("elem_metrics_row", "Stack", Map.of("direction", "horizontal", "gap", 4),
                List.of("card_spend", "card_trips"));

        builder.addLeaf("card_spend", "MetricCard", Map.of(
                "title", "近一年總消費",
                "value", "NT$ " + String.format("%,.0f", metrics.totalSpend()),
                "trend", metrics.totalSpend() > 100000 ? "up" : "neutral",
                "change", metrics.totalSpend() > 100000 ? "+22% 較前年" : "平穩"
        ));

        builder.addLeaf("card_trips", "MetricCard", Map.of(
                "title", "累積旅程數",
                "value", metrics.tripCount() + " 次",
                "trend", "neutral"
        ));

        // 5. 數據表格(資料列來自 Java 查詢結果,不讓 LLM 重新抄寫)
        List<Map<String, Object>> tableRows = metrics.trips().stream()
                .map(t -> Map.<String, Object>of(
                        "destination", t.destination(),
                        "date", t.date(),
                        "amount", "NT$ " + String.format("%,.0f", t.amount())
                ))
                .toList();

        builder.addLeaf("elem_trips_table", "DataTable", Map.of(
                "columns", List.of(
                        Map.of("key", "destination", "label", "目的地"),
                        Map.of("key", "date", "label", "出發日期"),
                        Map.of("key", "amount", "label", "行程金額")
                ),
                "rows", tableRows // 直接注入真實 List!
        ));

        return builder.build();
    }
}

這段程式的重點不是某個特定 UI 元件 API,而是資料流的邊界:metrics.trips() 產生的 rows 直接進入 Spec;LLM 只提供 InsightNarrative。前端的 json-render 收到完整 Spec 後才負責呈現。


5. 生產環境避坑指南與對比分析

常見踩雷與修正方式

  1. 雷區一:把含有敏感個資的整張 Table 餵進 LLM 只為了產出圖表
    • 現象:將包含身分證號、電話的 2,000 筆訂單記錄餵給 Prompt,要求產出銷售圖表。
    • 解法:Java 在後端先進行 SQL GROUP BY 聚合運算,只把必要的統計結果傳給 LLM 進行解讀;原始個資不必進入 Prompt。
  2. 雷區二:讓 LLM 在文字中自己發明格式化字串(如 NT$ 143,000
    • 現象:LLM 有時輸出 $143000,有時輸出 新台幣 14.3 萬,前端介面排版風格混亂。
    • 解法:數值格式化(Currency Formatter、Date Formatter)統一在 Java 或前端 component 內部處理,LLM 不負責決定顯示格式。
  3. 雷區三:LLM 輸出與真實數據打架(Semantic Inconsistency)
    • 現象:Java 算出的總消費是 14 萬,但 LLM 在摘要中寫「該客戶近一年無消費紀錄」。
    • 解法:把計算後的指標以結構化輸入提供給 LLM,並在組裝前檢查重要數字;Prompt 約束可以降低風險,但不能取代程式驗證。

資料生成架構 Good vs Bad 對比表

評估維度 ❌ 由 LLM 複製生成表格 (Bad) ✅ Java 確定性填空 + LLM 敘事 (Good)
數據準確度 重新抄寫時有竄改、遺漏與格式錯誤風險 來源可追溯,並可用程式測試與驗證
Token 成本 資料列越多,Prompt 或輸出越大 資料列不經過模型,不增加這部分 Token
大量資料 可能遇到上下文限制、截斷或破損 JSON 搭配查詢分頁與 renderer 渲染,資料量不必全部由 LLM 生成
回應速度 必須等待模型逐筆生成資料 不等待模型重抄資料,實際速度仍取決於 DB、服務與傳輸

6. 實機畫面:rows 由 Java 填入 DataTable

輸入「列出所有客戶」時,客戶資料(LTV、營收、狀態)由 Java 查詢資料庫後填入 DataTable.rows。LLM 負責解析需求與產生必要的敘事,沒有重新抄寫每一筆數字;最後由前端 renderer 呈現表格。表格右側的放大鏡是 Day 30 會用到的下鑽入口。

https://ithelp.ithome.com.tw/upload/images/20260824/20161290eEEbVQaMLV.png


7. 今日動手實作任務與發文備註

🛠️ 今日實作任務

  1. 實作 Compute-then-Compose:建立一個包含 10 筆假資料的 CustomerMetrics 物件,並透過純 Java 方法組裝出 DataTablerows
  2. 對比 Token 消耗:使用 tokenizer 或模型 API usage,實際比較「將 100 筆表格資料餵進 Prompt」與「只提供統計結果、請 LLM 產出摘要」的差異。
  3. 思考題:如果前端圖表需要展示「月份趨勢長條圖(BarChart)」,後端應該在 Java 先算好每個月的加總,還是讓 LLM 來計算?為什麼?

上一篇
Day 23:讓可用元件和可渲染元件對上
下一篇
Day 25:邊做邊看得到進度
系列文
讓 AI Agent 真的做事:用 Embabel 打造可控、可測試的智慧 Dashboard29
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言