iT邦幫忙

2025 iThome 鐵人賽

DAY 25
0
生成式 AI

AI 產品與架構設計之旅:從 0 到 1,再到 Day 2系列 第 25

Day 25: 別當控制狂,放手讓 AI 自己排版 - Schema vs. Template 的選擇

  • 分享至 

  • xImage
  •  

前情提要

嗨大家,我是 Debuguy。
昨天我們搞定了 Output Schema,讓 AI 輸出結構化的分析報告。今天要來解決下一個問題:

如何把這份報告,變成漂亮的 Slack 訊息?

控制狂的誘惑

當你開始思考這個問題時,第一個念頭通常是:

「我來設計一個完美的 template,我只要填空就好了!」

{
  "blocks": [
    {
      "type": "header",
      "text": `${title}`
    },
    {
      "type": "section", 
      "text": `${summary}`
    },
    {
      "type": "section",
      "text": `優先級:${priority}`
    },
    {
      "type": "section",
      "text": `建議:\n${recommendations}`
    }
  ]
}

看起來很完美對吧?格式統一、可預測、好維護。

但這就是「控制狂思維」的陷阱。

Template 的三大問題

問題 1:一體適用 = 沒有最適

想像一下這些情境:

情境 A:簡短回覆

  • 使用者:「現在系統狀況如何?」
  • AI:「一切正常」

你的 template 會產生:

【標題】系統狀態
【內容】一切正常
【優先級】低
【建議】無

過度排版了吧? 明明一句話就能說清楚。

情境 B:複雜分析

  • 多個錯誤來源
  • 需要分層說明
  • 有程式碼片段要引用

你的 template:

【建議】
1. 檢查 A
2. 檢查 B  
3. 檢查 C
4. 檢查 D
5. 檢查 E

太單調了吧? 應該要有重點強調、應該要有 emoji 提示優先級。

問題 2:你真的比 AI 會排版?

Slack Block Kit 有這麼多種元素:

  • 粗體斜體、~刪除線~、code
  • 🔴🟡🟢 emoji 提示
  • 列表、引用、分隔線
  • User mention、連結

如果內容需要強調某個關鍵數字,AI 知道該用粗體。
如果建議有急迫性,AI 知道該加 🚨 emoji。
如果要 mention 特定人員,AI 知道該用 <@USER_ID>

你在寫 template 的時候,能預測所有情況嗎?

問題 3:維護的惡夢

當你想要:

  • 調整排版風格
  • 加入新的區塊
  • 針對不同情境優化

你需要改 template,然後測試所有情境。

但如果是 AI 自己決定呢?你只需要調整 prompt,AI 會自己適應。

放手的哲學:Schema > Template

我的選擇是:

「給 AI 工具箱,不給固定模板。」

// 不是這樣(固定 template)
const template = {
  header: `${title }`,
  section: `${content}`
};

// 而是給予 Schema
ai.prompt<typeof SlackBotPromptInputSchema, typeof SlackMessageContentsSchema>('chat_bot')

這就是 Schema vs Template 的核心差異:

面向 Template Schema
彈性 固定格式 AI 自主決定
適應性 一體適用 因地制宜
維護 改 code 調 prompt
信任 不相信 AI 相信 AI

但也不能完全放牛吃草

這裡有個平衡點:

❌ 完全不給限制 → AI 可能亂用、格式不一致、甚至產生不合法的 JSON

✅ Schema + Example → 給範圍,但保留彈性

策略 1:精簡 Schema,省 Token

Slack Block Kit 有十幾種 block types,但我只需要三種:

export const KnownBlockSchema = z.union([
  HeaderBlockSchema,      // 大標題
  SectionBlockSchema,     // 內容區塊  
  RichTextBlockSchema,    // 格式化文字 (最主要是使用其中的 list)
]);

為什麼只要三種?

因為這三種就能組合出 90% 的排版需求:

  • Header:標題
  • Section:一般內容、重點摘要
  • Rich Text:列表、強調、連結、emoji

省下來的 token 可以讓 AI 有更多 context。

比起把整個 Slack Block Kit schema(幾千個 token)塞進去,這樣的設計:

  • ✅ 省下 token 成本
  • ✅ 讓 AI 專注於真正重要的部分
  • ✅ 降低 AI 選擇困難的機率

完整的 Schema 定義

import { z } from 'genkit';

const PlainTextElementSchema = z.object({
  type: z.literal('plain_text'),
  /// The text for the block. Minimum length 1 and maximum length 3000 characters.
  text: z.string().min(1).max(3000).describe('Text content (1-3000 chars).'),
  /// Indicates whether emojis in a text field should be escaped into the colon emoji format.
  emoji: z.boolean().optional().describe('If true, emit emojis as :shortcodes:.'),
}).describe('Defines an object containing some text.');

/// Displays a larger-sized text block. A `header` is a plain-text block that displays in a larger, bold font. Use it to delineate between different groups of content in your app's surfaces.
const HeaderBlockSchema = z.object({
  type: z.literal('header'),
  /// Plain text content, limited to 150 characters.
  text: PlainTextElementSchema,
}).describe('header for prominent sections.');

/// Markdown element definition used in Slack block kit.
const MrkdwnElementSchema = z.object({
  type: z.literal('mrkdwn'),
  /// This field accepts any of the standard text formatting markup. The minimum length is 1 and maximum length is 3000 characters.
  text: z.string().min(1).max(3000).describe('Markdown content (1-3000 chars).'),
  /// When set to `false` (as is default) URLs will be auto-converted into links, conversation names will be link-ified, and certain mentions will be automatically parsed. Using a value of `true` will skip any preprocessing of this nature, although you can still include manual parsing strings
  verbatim: z.boolean().optional().describe('Disable Slack auto-formatting.'),
}).describe('Slack mrkdwn text is not normal markdown, it supports *bold*, _italic_, ~strike~, `code`, >quotes, <https://…|label> links, and entity mentions like <@U123>/<#C123> etc.');

const TextObjectSchema = z.union([
  PlainTextElementSchema,
  MrkdwnElementSchema,
]);

/// Displays text, possibly alongside block elements. A section can be used as a simple text block, in combination with text fields, or side-by-side with certain interactive elements.
const SectionBlockSchema = z.object({
  type: z.literal('section'),
  /// The text for the block, in the form of a TextObject. Minimum length for the `text` in this field is 1 and maximum length is 3000 characters.
  text: TextObjectSchema.optional().describe('Section body text (1-3000 chars).'),
  /// When true, renders the section text expanded by default.
  expand: z.boolean().optional().describe('Set true to prevent Slack from collapsing lengthy content.'),
}).describe('Slack section block mixing text and elements.');


/// A limited style object for styling rich text `text` elements.
const RichTextStyleableSchema = z.object({
  style: z.object({
    /// When `true`, boldens the text in this element. Defaults to `false`.
    bold: z.boolean().optional().describe('Request bold styling in Slack rich_text output.'),
    /// When `true`, the text is preformatted in an inline code style. Defaults to `false.`
    code: z.boolean().optional().describe('Render this span with inline code styling.'),
    /// When `true`, italicizes the text in this element. Defaults to `false`.
    italic: z.boolean().optional().describe('Request italic styling in Slack rich_text output.'),
    /// When `true`, strikes through the text in this element. Defaults to `false`.
    strike: z.boolean().optional().describe('Render this span with strikethrough styling.'),
  }).optional(),
}).describe('Rich text style toggles; enable only one flag per span.');

/// For use in setting border style details on certain Rich Text elements.
const RichTextBorderableSchema = z.object({
  /// Whether to render a quote-block-like border on the inline side of the list. `0` renders no border while `1` renders a border.
  border: z.union([z.literal(0), z.literal(1)]).optional().describe('0 keeps default margin, 1 adds Slack quote-style border.'),
}).describe('Optional border styling metadata for rich_text lists.');

/// A hex color element for use in a rich text message.
const RichTextColorSchema = RichTextStyleableSchema.extend({
  type: z.literal('color'),
  /// Hex value for the color.
  value: z.string().describe('Six-digit hex color used to tint following elements.'),
}).describe('Payload for the rich_text `color` element.');

/// An emoji element for use in a rich text message.
const RichTextEmojiSchema = RichTextStyleableSchema.extend({
  type: z.literal('emoji'),
  /// Name of emoji, without colons or skin tones, e.g. `wave`
  name: z.string().describe('Slack emoji short name (no colons).'),
  /// Lowercase hexadecimal Unicode representation of a standard emoji (not for use with custom emoji).
  unicode: z.string().optional().describe('Lowercase hexadecimal Unicode for built-in emoji variants.'),
}).describe('Payload for the rich_text `emoji` element.');

/// A link element for use in a rich text message.
const RichTextLinkSchema = RichTextStyleableSchema.extend({
  type: z.literal('link'),
  /// The text to link.
  text: z.string().optional().describe('Optional label Slack shows instead of the raw URL.'),
  /// URL to link to.
  url: z.string().describe('Destination opened when the user clicks the link.'),
}).describe('Payload for the rich_text `link` element.');

/// A generic text element for use in a rich text message.
const RichTextTextSchema = RichTextStyleableSchema.extend({
  type: z.literal('text'),
  /// The text to render.
  text: z.string().describe('Plain-text span shown inside the rich_text block.'),
}).describe('Payload for the rich_text `text` element.');

/// A user mention element for use in a rich text message.
const RichTextUserMentionSchema = RichTextStyleableSchema.extend({
  type: z.literal('user'),
  /// The encoded user ID, e.g. U1234ABCD.
  user_id: z.string().describe('Slack user ID to mention in-line.'),
}).describe('Payload for the rich_text `user` mention element.');

const RichTextElementSchema = z.union([
  RichTextColorSchema,
  RichTextEmojiSchema,
  RichTextLinkSchema,
  RichTextTextSchema,
  RichTextUserMentionSchema,
]);

const RichTextSectionSchema = z.object({
  type: z.literal('rich_text_section'),
  elements: z.array(RichTextElementSchema),
});

/// A list block within a rich text field.
const RichTextListSchema = RichTextBorderableSchema.extend({
  type: z.literal('rich_text_list'),
  elements: z.array(RichTextSectionSchema),
  /// The type of list. Can be either `bullet` (the list points are all rendered the same way) or `ordered` (the list points increase numerically from 1).
  style: z.union([z.literal('bullet'), z.literal('ordered')]).describe('Choose bullet points or numbered ordering.'),
  /// The style of the list points. Can be a number from `0` (default) to `8`. Yields a different character or characters rendered as the list points. Also affected by the `style` property.
  indent: z.number().int().min(0).max(8).optional().describe('Indent preset Slack uses to vary glyph shapes (0-8).'),
}).describe('Slack rich text list block.');

/// Displays formatted, structured representation of text. It is also the output of the Slack client's WYSIWYG message composer, so all messages sent by end-users will have this format. Use this block to include user-defined formatted text in your Block Kit payload. While it is possible to format text with `mrkdwn`, `rich_text` is strongly preferred and allows greater flexibility.
const RichTextBlockSchema = z.object({
  type: z.literal('rich_text'),
  elements: z.array(RichTextListSchema),
}).describe('rich_text block emitted by Slack’s WYSIWYG composer.');

export const KnownBlockSchema = z.union([
  HeaderBlockSchema,
  RichTextBlockSchema,
  SectionBlockSchema,
]);

/// Slack message with Block Kit formatting.
export const MessageContentsSchema = z.object({
  /// Text of the message. If used in conjunction with `blocks`, `text` will be used as fallback text for notifications only.
  text: z.string().describe('Fallback text Slack surfaces in push/email notifications.'),
  /// An array of structured Blocks.
  blocks: z.array(KnownBlockSchema).min(1).max(50).optional().describe('Structured Block Kit payload (up to 50 blocks).'),
}).describe('Slack chat.postMessage payload combining text and blocks.');

export type MessageContentsType = z.infer<typeof MessageContentsSchema>;

策略 2:Schema + Example 組合拳

關鍵洞察:JSON Schema 不保證每次的輸出都沒失誤。

LLM 有時候會:

  • 忘記必填欄位
  • 搞混巢狀結構
  • 用錯 type literal

所以我在 prompt 裡:

## Output Format
you must follow the output json Format, it is Slack MessageContent schema
here is a minimal example:

    {
      "text": "example",
      "blocks": [
        {
          "type": "header",
          "text": {
            "type": "plain_text",
            "text": "This is a header block"
          }
        },
        {
          "type": "section",
          "text": {
            "type": "mrkdwn",
            "text": "This is a mrkdwn section block :ghost: *this is bold*, and ~this is crossed out~, and <https://google.com|this is a link>"
          }
        },
        {
          "type": "rich_text",
          "elements": [
            {
              "type": "rich_text_list",
              "style": "bullet",
              "elements": [
                {
                  "type": "rich_text_section",
                  "elements": [
                    {
                      "type": "emoji",
                      "name": "basketball"
                    },
                    {
                      "type": "text",
                      "text": "this is a list item"
                    },
                    {
                      "type": "link",
                      "url": "https://example.com/"
                    }
                  ]
                }
              ]
            }
          ]
        }
      ]
    }

這就是 Schema + Example 的組合拳:

  • Schema 說明規則:告訴 AI「可以用哪些欄位」
  • Example 示範格式:告訴 AI「正確的長相是什麼」

就像教小孩寫作文:

  • ❌ 只給規則:「要有起承轉合、要用成語」→ 小孩不知道怎麼寫
  • ✅ 規則 + 範文:給一篇範例,小孩就知道該怎麼做了

這個範例涵蓋了所有三種 block types,而且展示了:

  • Header 的 plain_text 格式
  • Section 的 mrkdwn 語法(粗體、刪除線、連結、emoji)
  • Rich Text 的複雜巢狀結構(list > section > 多種 elements)

有了這個範例,AI 出錯的機率大幅下降。

成果展示*

策略 3:用 Description 引導而非限制

注意看每個 schema 的 describe()

const MrkdwnElementSchema = z.object({
  type: z.literal('mrkdwn'),
  text: z.string().min(1).max(3000).describe('Markdown content (1-3000 chars).'),
  verbatim: z.boolean().optional().describe('Disable Slack auto-formatting.'),
}).describe('Slack mrkdwn text is not normal markdown, it supports *bold*, _italic_, ~strike~, `code`, >quotes, <https://…|label> links, and entity mentions like <@U123>/<#C123> etc.');

這些不是給人看的註解,是給 AI 看的使用說明。

就像你給同事一份 API 文件一樣,你在告訴 AI:

  • 這個欄位是做什麼的
  • 什麼時候應該用它
  • 有什麼限制

但你沒有說「你必須這樣用」,而是「這是可選的工具」。

AI 自主決策的實際案例

讓我們看看 AI 實際上會怎麼排版:

案例 1:簡短回覆

使用者問: 「系統現在正常嗎?」

AI 輸出:

{
  "text": "系統一切正常",
  "blocks": [
    {
      "type": "section",
      "text": {
        "type": "mrkdwn",
        "text": "✅ 系統一切正常,所有服務運作順暢。"
      }
    }
  ]
}

注意:

  • 只用一個 section block,簡潔明瞭
  • 自己加了 ✅ emoji 表示正面
  • 沒有多餘的 header 或列表

案例 2:複雜分析

使用者問: 「分析這個 Grafana 警報」

AI 輸出:

{
  "text": "API 5xx 錯誤率異常",
  "blocks": [
    {
      "type": "header",
      "text": {
        "type": "plain_text",
        "text": "🔴 API 5xx 錯誤率異常"
      }
    },
    {
      "type": "section",
      "text": {
        "type": "mrkdwn",
        "text": "*摘要:* Auth Service 的 database 連線池耗盡導致大量 5xx 錯誤\n\n*信心水準:* 高 (95%) - 日誌證據充足,錯誤模式清晰"
      }
    },
    {
      "type": "section",
      "text": {
        "type": "mrkdwn",
        "text": "*立即處理建議:*"
      }
    },
    {
      "type": "rich_text",
      "elements": [
        {
          "type": "rich_text_list",
          "style": "bullet",
          "elements": [
            {
              "type": "rich_text_section",
              "elements": [
                {
                  "type": "emoji",
                  "name": "rotating_light"
                },
                {
                  "type": "text",
                  "text": " 檢查 database 連線池配置"
                }
              ]
            },
            {
              "type": "rich_text_section",
              "elements": [
                {
                  "type": "emoji",
                  "name": "chart_with_upwards_trend"
                },
                {
                  "type": "text",
                  "text": " 增加監控告警"
                }
              ]
            }
          ]
        }
      ]
    }
  ]
}

注意:

  • 用 header 強調嚴重性,還加了 🔴
  • Section 裡用粗體標示關鍵資訊
  • Rich text list 每個項目都加上對應的 emoji
  • 整體層次分明、重點清晰

這些都是 AI 自己決定的,我沒有在 template 裡寫死。

技術實作細節

在 GenKit 中註冊 Schema

const SlackMessageContentsSchema = ai.defineSchema(
  'SlackMessageContentsSchema', 
  MessageContentsSchema
);

const SlackBotPromptInputSchema = ai.defineSchema(
  'SlackBotPromptInputSchema', 
  z.object({
    botUserId: z.string(),
    prompt: z.array(z.string())
  })
);

在 dotPrompt 中引用:

---
model: googleai/gemini-2.5-flash
input:
  schema: SlackBotPromptInputSchema
output:
  schema: SlackMessageContentsSchema
---

關注點分離:內容 vs Metadata

有趣的是,雖然我讓 AI 自己決定排版,但在後端我還是可以追加一些固定的資訊:

// AI 輸出的內容
response.text.blocks = [/* AI 自己決定的排版 */];

// 後端統一追加 metadata
response.text.blocks.push({ type: "divider" });
response.text.blocks.push({
  type: "rich_text",
  elements: [{
    type: "rich_text_list",
    style: "bullet",
    elements: [
      { /* input tokens */ },
      { /* thoughts tokens */ },
      { /* output tokens */ }
    ]
  }]
});
response.text.blocks.push({
  type: "section",
  text: {
    type: "mrkdwn",
    text: `<${langfuseUrl}|${response.traceId}>`
  }
});

這個設計很棒:

  • AI 專注於內容:回答問題、選擇排版
  • 後端處理 metadata:統一格式顯示 usage 和 trace link
  • 各司其職:不會混在一起

放手的價值

回到標題:別當控制狂,放手讓 AI 自己排版。

這個設計帶來什麼好處?

1. 更好的適應性

AI 可以根據內容:

  • 簡短就簡短,複雜就詳細
  • 需要強調就用粗體和 emoji
  • 有多個項目就用列表
  • 要 mention 人就用 user mention

每次輸出都是「剛剛好」,而不是「一體適用」。

2. 更低的維護成本

想要調整風格?改 prompt:

# 想要更正式的風格
Be professional and avoid using emojis unless necessary.

# 想要更活潑的風格  
Use appropriate emojis to make the message more engaging.

不用改 code,不用測試所有情境。

3. Type Safety

用 Zod + TypeScript 確保輸出一定符合 Slack API 規範:

export type MessageContentsType = z.infer<typeof MessageContentsSchema>;

VSCode 的 IntelliSense 會提示你可以用哪些欄位,編譯時期就能抓出問題。

小結

今天我們學到了「放手」的藝術:

核心觀念:

  • ❌ Template 看似安全,實則僵化
  • ✅ Schema 給予彈性,信任 AI 的判斷
  • 🎯 Schema + Example 組合拳,兼顧正確性

設計原則:

  • 精簡但不限制(只保留必要的 block types)
  • 引導但不控制(用 description 說明而非強制)
  • 分離關注點(內容 vs metadata)

實際效益:

  • 更好的適應性:因地制宜的排版
  • 更低的維護成本:改 prompt 不改 code
  • Token 效率:省下的用在更重要的地方
  • Type safety:Zod + TypeScript 保證正確

哲學思考:

「給工具,不給限制。」
「相信 AI 比你更懂排版。」


AI 的發展變化很快,目前這個想法以及專案也還在實驗中。但也許透過這個過程大家可以有一些經驗和想法互相交流,歡迎大家追蹤這個系列。

也歡迎追蹤我的 Threads @debuguy.dev


上一篇
Day 24: 當 AI 報告需要「格式規範」- Output Schema 的第一步
下一篇
Day 26: 你的 AI 有多聰明?來驗收一下吧 - Genkit Evaluation 介紹
系列文
AI 產品與架構設計之旅:從 0 到 1,再到 Day 227
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言