iT邦幫忙

2026 iThome 鐵人賽

DAY 18
0
佛心分享-SideProject30

營養師想做一個飲食建議產品系列 第 18 篇

Day18 - Structured Output / JSON

  • 分享至 

  • xImage
  •  

AI 回的是一篇文章,但程式要的是一個可以 JSON.parse() 的物件

🧱 地基概念

LLM 本質上是「接龍文字」,不是「填表單」

LLM 是逐字生成下一個最可能的字,本質上是個「接龍文字」的模型。就算 prompt 裡明講「只回 JSON,不要其他文字」,它在語意上理解了這個要求,但生成過程沒有強制保證輸出百分之百是合法的 JSON 格式——實務上還是可能在前後多包一些不屬於 JSON 的字元。這也是為什麼「Structured Output」(結構化輸出)會被特別拉出來當一個要解決的問題:AI 的輸出跟程式碼能安全解析的資料之間,中間需要一層處理。

先弄清楚:我們到底在解決什麼?

AI Diet Copilot 的流程是:使用者寫一段自由文字 → AI 分析 → AI 生成菜單 → 前端畫面顯示。這中間 AI 的每一次回覆,都是要交給程式碼繼續處理的,不是給人看的,所以 AI 的回覆必須是程式讀得懂的固定格式,而不是一篇順順的文章。

「AI 的輸出能不能直接拿來用」其實有兩層問題:

  1. 格式對不對:是不是合法的 JSON?欄位名稱、型態是不是照我們要的?(Day18 在討論的)
  2. 內容可不可信:格式對了,裡面的數字、資料是不是正確的?(這是 Day19 要討論的)

我們是怎麼做的:四個環節接力

① 在 prompt 裡直接寫死 JSON 結構

不是只說「請回 JSON」,而是把要的欄位、型態整份貼進 prompt,讓 AI 照著填空。以呼叫 #1(飲食型態分析)為例:

{
  "mealPattern": {
    "mealsPerDay": number,
    "skipsBreakfast": boolean,
    "hasLateNightSnack": boolean
  },
  "preferences": {
    "spicy": boolean,
    "likedFoods": string[]
  }
}

(此為節錄,完整版有更多欄位,完整版請參考Day17)prompt 裡還寫了「請嚴格依照以下 JSON 格式輸出,不要輸出任何 JSON 以外的文字」。這能大幅提高 AI 聽話的機率,但只是「請求」,不是「保證」。)

② 用共用函式 invokeClaudeForJson() 接住 AI 的回覆

程式不直接信任 AI 的原始回覆,而是統一走同一條流程:送 prompt → 取出回覆文字 → 剝掉可能多出來的 code fence(下面「踩雷」會細講)→ JSON.parse()。

③ 用 TypeScript 型別告訴程式「這包資料長什麼樣」

問題:JSON.parse() 回來的東西,程式不知道裡面有什麼。 它的結果對編譯器來說是「任何東西都有可能」,像一個沒貼標籤的箱子。此時如果寫:

const data = JSON.parse(text);
data.mealPattern.skipBreakfast   
// 這裡的skipBreakfast少打了一個 s,應該要是skip"s"Breakfast
// 編輯器不會提醒,要到執行時才發現是 undefined

解法:先定義型別,再幫資料貼標籤。 在專案裡先用 TypeScript 的 interface 定義「飲食型態分析」應該長什麼樣子(即 prompt 裡要求 AI 填的那份 JSON 結構):

interface DietaryAnalysis {
  mealPattern: {
    mealsPerDay: number;
    skipsBreakfast: boolean;
    hasLateNightSnack: boolean;
  };
  preferences: {
    spicy: boolean;
    likedFoods: string[];
  };
}

(此為示範而已,完整版有更多欄位)然後在呼叫端用 as 幫回傳的資料「貼標籤」:

// 用 DietaryAnalysis 來定義型別
export async function analyzeDietaryFreeText(freeText: string, lifestyle: Lifestyle): Promise<DietaryAnalysis> {
  const prompt = buildAnalysisPrompt(freeText, lifestyle);
  return (await invokeClaudeForJson(prompt)) as DietaryAnalysis;
}

as DietaryAnalysis 就是在說:「這個箱子裡的東西,我保證它是 DietaryAnalysis 的形狀」。貼完標籤後有兩個好處:

  • 後面的程式寫 analysis.mealPattern. 就會跳出欄位提示,不用背欄位名
  • 拼成 skipBreakfast 編輯器立刻標紅線,在執行前就能抓到

但標籤只是「聲明」,不是「檢查」。 as DietaryAnalysis 只是告訴編譯器「相信我,它是這個形狀」,程式實際執行時並不會真的檢查 AI 有沒有照做。如果 AI 實際回的是:

{ "mealPattern": { "mealsPerDay": "三餐" } }

(數字變成字串、skipsBreakfast 也漏掉)編譯器照樣放行,錯誤要等到後面用到時才爆,或默默算出錯誤的結果。

可以想成在箱子外面貼「內容物:蘋果」的標籤:標籤讓拿東西方便,但不代表箱子裡真的是蘋果,要打開檢查才知道。所以格式之外的可信度,還需要下一個環節。

④ 不完全信任內容,另外設計驗證機制

格式合法不代表內容正確,所以另外做了幾層保護,例如:菜單每餐的份數加總後,要跟程式算出的當日目標比對,落差太大就重打一次;AI 選了哪些食物只讓它回編號,營養數字由程式回資料庫查(下面「設計取捨」會講)。

一句話總結這四個環節:prompt 負責「請 AI 照格式回」,invokeClaudeForJson() 負責「把回覆安全變成資料」,型別負責「讓後面的程式好寫」,驗證機制負責「不讓錯誤的內容一路流到畫面上」。

🔧 實際操作

踩雷:Haiku 4.5 把 JSON 包了一層 code fence

即使 prompt 明確要求「只回 JSON」,實測 Haiku 4.5 還是會在回應前後加上一層 Markdown 程式碼區塊標記(三個反引號加 json、結尾再一次三個反引號),這就是所謂的 code fence。直接拿去 JSON.parse() 會失敗,因為前後多了不屬於合法 JSON 的字元。

為什麼 AI 會這樣?

  • 訓練資料的習慣:LLM 學到的文字裡,JSON 或程式碼幾乎都出現在 Markdown 的程式碼區塊裡(技術文章、文件、問答網站都是),所以一被要求「給 JSON」,它很自然就照最常見的格式輸出。
  • 聊天模型的預設風格:Claude 這類模型是訓練成給人看的對話助手,而 code fence 在聊天介面裡會被渲染成漂亮的程式碼區塊,所以它預設就會這樣排版。
  • prompt 是請求,不是強制:這就是前面「接龍文字」的特性,「只回 JSON」只能提高機率,不能保證。

**為什麼值得特別記錄?**因為它很隱蔽:prompt 沒寫錯、AI 的回覆內容也完全正確,只是前後多了幾個字元就讓程式整個失敗;而且不是每次都發生(這次包了下次可能沒包),也是第一次串 LLM API 最常見的坑之一。

我以為拿到的(乾淨 JSON):

{ "meals": [ { "meal": "午餐", "suggestion": "..." } ] }

實際拿到的(多了前後兩行):

(三個反引號)json
{ "meals": [ { "meal": "午餐", "suggestion": "..." } ] }
(三個反引號)
SyntaxError: Unexpected token '`' ... is not valid JSON

解法是在解析前先剝除這層 code fence,再丟給 JSON.parse()。核心邏輯只有兩行(正則直接寫在 match() 裡):

const codeFenceMatch = rawText.trim().match(/^```(?:json)?\s*([\s\S]*?)\s*```$/);
const jsonText = codeFenceMatch ? codeFenceMatch[1] : rawText;

真正的正則表達式就是 /.../ 之間的這一串。我們把它拆開來看:

片段 白話
^ 從文字最開頭開始比對
三個反引號 code fence 的開頭標記
(?:json)? 後面可以接 json,也可以沒有(? 代表「有或沒有」)
\s* 吃掉中間可能的空白與換行
([\s\S]*?) 拿來「抓出中間內容」:[\s\S] 是任何字元(含換行),*? 是盡量少抓;外面的括號代表「把這段記下來」
\s* 吃掉結尾前的空白與換行
三個反引號 + $ 結尾也必須是三個反引號

白話講就是:「如果整段文字是『開頭三個反引號(可能接 json)、中間任意內容、結尾三個反引號』,就把中間那段抓出來」。比對成功時,codeFenceMatch[1] 就是括號抓到的中間內容(乾淨的 JSON);比對不到(代表 AI 這次沒包 code fence)就原樣使用 rawText。

完整的 invokeClaudeForJson() 就是把上面這一段放進一條完整的流程:

async function invokeClaudeForJson(prompt: string, maxTokens = 1024): Promise<unknown> {
  // 1. 送出 prompt 給 Bedrock 上的 Claude
  const command = new InvokeModelCommand({
    modelId: MODEL_ID,
    contentType: 'application/json',
    accept: 'application/json',
    body: JSON.stringify({
      anthropic_version: 'bedrock-2023-05-31',
      max_tokens: maxTokens,
      messages: [{ role: 'user', content: prompt }],
    }),
  });
  const response = await client.send(command);

  // 2. Bedrock 回傳的 body 是位元組,先解碼成字串、再 parse 成物件
  const responseBody = JSON.parse(new TextDecoder().decode(response.body));

  // 3. Claude 的回覆文字在 content[0].text
  const rawText: string = responseBody.content[0].text;

  // 4. 剔掉可能多出來的 code fence(沒包就原樣使用)
  const codeFenceMatch = rawText.trim().match(/^```(?:json)?\s*([\s\S]*?)\s*```$/);
  const jsonText = codeFenceMatch ? codeFenceMatch[1] : rawText;

  // 5. 真正的 JSON.parse;失敗時先把 AI 的原始回覆印出來方便事後查問題,再把錯誤丟出去
  try {
    return JSON.parse(jsonText);
  } catch (err) {
    console.log('Failed to parse Bedrock response as JSON:', rawText);
    throw err;
  }
}

幾個細節:第 2 步的 JSON.parse 是在解 Bedrock 的「外層信封」,第 5 步才是在解 AI 回的「裡面的信」,兩次不是同一件事;回傳型別寫 Promise<unknown>,意思是「這包資料目前長什麼樣還不知道」,由呼叫端自己負責標記型別(見前面的③)。

設計取捨:用 foodId 引用,不是整包塞資料

先講問題:菜單生成(呼叫 #2)要 AI 推薦「這餐吃什麼」。最直覺的做法是請 AI 連營養資料一起寫出來,例如「7-11 鮪魚飯糰,熱量 180 大卡、全穀雜糧 1.5 份……」。但 AI 是憑印象在「接龍」,這些數字可能寫得很像真的,其實是錯的,而且每次還可能不一樣。

後來用的做法像點餐:不讓 AI 背菜單,而是把「候選食物清單」(來自 Food DB)貼進 prompt,每個品項都有一個編號 foodId,請 AI 只要回報「我選了哪幾號」。

送給 AI 的候選清單(示意):

[
  { "foodId": "711-001", "brand": "7-11", "name": "鮪魚飯糰", "servingSize": "1 個" },
  { "foodId": "fm-042", "brand": "全家", "name": "無糖豆漿", "servingSize": "400ml" }
]

AI 回傳的 JSON(示意,只有編號,沒有營養數字):

{
  "meal": "午餐",
  "suggestion": "7-11 鮪魚飯糰搭配全家無糖豆漿",
  "referencedFoodIds": ["711-001", "fm-042"]
}

程式拿到編號後,回頭去 Food DB 查出這兩樣食物真正的營養資料,再由程式精確算出這一餐的六大類份數。整個流程是:

  1. 程式把候選清單(含編號)給 AI
  2. AI 只回編號 referencedFoodIds
  3. 程式用編號查 Food DB,算出正確份數

這樣做的好處:

  • AI 只負責「選誰」,營養數字全由程式從資料庫算,不會憑印象亂寫
  • 就算 AI 亂填一個不存在的編號,程式查不到就直接忽略、退回 AI 自己的估算,不會讓整個請求壞掉
  • 這就是專案裡「AI 不算營養、程式算營養」這條原則實際落實的地方

補充:如果候選清單是空的、或都不適合,AI 仍會自己估一份份數當備案(referencedFoodIds 留空陣列),只有「有選到真實食物」的餐次,才會被程式算出的數字覆蓋。

小結

Structured Output 不是「跟 AI 說清楚格式」就結束了,還要在「AI 輸出」跟「程式碼使用」之間留一層防禦性的解析/驗證邏輯,而且更根本的做法是——透過設計,讓 AI 沒有機會、也沒有必要去覆述本來就該由程式碼掌握的精確資料。就算格式穩了,還有一個更根本的問題沒解決:AI 講得有條有理,不代表它是對的——這是下一篇要討論的事。


上一篇
Day17 - Prompt Engineering
下一篇
Day19 - Hallucination:為什麼不能完全相信 AI ?
系列文
營養師想做一個飲食建議產品 共 19 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言