
前幾天我們已經介紹 json-render 的 Spec、Flat Tree 與 Catalog。今天不再重複介紹元件結構,而是往前追問一個更重要的問題:Spec 裡面的數字和資料列,究竟應該由誰產生?
在 Generative UI 專案中,許多初學者常犯一個架構錯誤:把資料庫查出的 100 筆客戶旅遊資料全部塞進 Prompt,叫 LLM「請幫我把這些資料轉成 DataTable 的 JSON Spec」。
這種做法在 Demo 時看似沒問題,但一旦上線就會引發四種災難性後果:
45,000,LLM 抄著抄著可能幻覺寫成 48,000,在金融與訂單場景直接構成商業詐欺。今天我們將貫徹 Compute-then-Compose(先運算,後組裝):凡是資料列、統計指標與排序結果,先由 SQL、Java 或其他確定性程式算好;LLM 負責洞察與敘事,最後再由 Java 組裝成 json-render 可以呈現的 UI Spec。
這個原則不只適用於 Generative UI。報表、訂單、財務、庫存與任何需要精確數字的 Agent 流程,都應該把「計算事實」和「解釋事實」分開處理。
json-render 的工作是接收符合約定的 UI Spec,並把它渲染成畫面;它不是資料庫,也不是計算器。前端可以透過 schema 與 registry 檢查 Spec 結構是否符合契約,但不會替我們判斷「總金額是否算對」。
可以把完整流程想成四個責任區域:
使用者需求
↓
Embabel Agent:理解需求、決定要呼叫哪些能力
├── SQL / Java:查詢、統計、排序與格式化真實數據
└── LLM:根據計算結果產生洞察與敘事
↓
Java:把數據與敘事組裝成 DashboardSpec
↓
json-render:把 UI Spec 渲染成畫面
因此,DataTable.props.rows、BarChart.props.data 與 MetricCard.props.value 的數值,應該來自程式計算;Summary.props.explanation、AlertBanner.props.message 等文字,才適合交給 LLM 產生。
這樣分工的重點不是「LLM 完全不能碰數字」,而是:需要可驗證的數字,不能只依賴 LLM 的文字生成結果。

權責劃分法則:
DataTable.props.rows、BarChart.props.data、MetricCard.props.value、聚合、分頁、排序與數值格式化。AlertBanner.props.message(風險提示)、Heading.props.text、Summary.props.explanation(趨勢洞察)。在 Embabel 中,我們不把所有事情塞在一個 Action 裡,而是分為三階段 Action:
FetchDataAction(Java):從 CRM / DB 查出強型別 TravellerActivity 與計算指標。AnalyzeInsightAction(LLM):呼叫 Spring AI 生成結構化的 ActivityNarrative(包含摘要、風險、推薦方案)。AssembleDashboardAction(純 Java):接收前兩者的輸出,透過 DashboardSpecBuilder 組裝成完整的 DashboardSpec。最後才由前端的 json-render 讀取這份 Spec。換句話說,renderer 是流程的呈現端,不是數據的來源端。
因為 DataTable 的 rows 陣列完全不經過 LLM,因此:
在最終組裝前,Java 可以檢查必要的指標、欄位與資料來源是否存在。若 LLM 敘事需要提到數字,應把已計算好的指標以結構化資料提供給它,並對重要數字做回查;發現矛盾時,以可追溯的 Java 計算結果為準,而不是直接相信模型文字。
以下實作完整的後端三階 Action 與組裝器。
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
) {}
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 後才負責呈現。
GROUP BY 聚合運算,只把必要的統計結果傳給 LLM 進行解讀;原始個資不必進入 Prompt。NT$ 143,000)
$143000,有時輸出 新台幣 14.3 萬,前端介面排版風格混亂。| 評估維度 | ❌ 由 LLM 複製生成表格 (Bad) | ✅ Java 確定性填空 + LLM 敘事 (Good) |
|---|---|---|
| 數據準確度 | 重新抄寫時有竄改、遺漏與格式錯誤風險 | 來源可追溯,並可用程式測試與驗證 |
| Token 成本 | 資料列越多,Prompt 或輸出越大 | 資料列不經過模型,不增加這部分 Token |
| 大量資料 | 可能遇到上下文限制、截斷或破損 JSON | 搭配查詢分頁與 renderer 渲染,資料量不必全部由 LLM 生成 |
| 回應速度 | 必須等待模型逐筆生成資料 | 不等待模型重抄資料,實際速度仍取決於 DB、服務與傳輸 |
輸入「列出所有客戶」時,客戶資料(LTV、營收、狀態)由 Java 查詢資料庫後填入 DataTable.rows。LLM 負責解析需求與產生必要的敘事,沒有重新抄寫每一筆數字;最後由前端 renderer 呈現表格。表格右側的放大鏡是 Day 30 會用到的下鑽入口。

CustomerMetrics 物件,並透過純 Java 方法組裝出 DataTable 的 rows。