嗨大家,我是 Debuguy。
昨天我們搞定了 Output Schema,讓 AI 輸出結構化的分析報告。今天要來解決下一個問題:
如何把這份報告,變成漂亮的 Slack 訊息?
當你開始思考這個問題時,第一個念頭通常是:
「我來設計一個完美的 template,我只要填空就好了!」
{
"blocks": [
{
"type": "header",
"text": `${title}`
},
{
"type": "section",
"text": `${summary}`
},
{
"type": "section",
"text": `優先級:${priority}`
},
{
"type": "section",
"text": `建議:\n${recommendations}`
}
]
}
看起來很完美對吧?格式統一、可預測、好維護。
但這就是「控制狂思維」的陷阱。
想像一下這些情境:
情境 A:簡短回覆
你的 template 會產生:
【標題】系統狀態
【內容】一切正常
【優先級】低
【建議】無
過度排版了吧? 明明一句話就能說清楚。
情境 B:複雜分析
你的 template:
【建議】
1. 檢查 A
2. 檢查 B
3. 檢查 C
4. 檢查 D
5. 檢查 E
太單調了吧? 應該要有重點強調、應該要有 emoji 提示優先級。
Slack Block Kit 有這麼多種元素:
code
如果內容需要強調某個關鍵數字,AI 知道該用粗體。
如果建議有急迫性,AI 知道該加 🚨 emoji。
如果要 mention 特定人員,AI 知道該用 <@USER_ID>
。
你在寫 template 的時候,能預測所有情況嗎?
當你想要:
你需要改 template,然後測試所有情境。
但如果是 AI 自己決定呢?你只需要調整 prompt,AI 會自己適應。
我的選擇是:
「給 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 → 給範圍,但保留彈性
Slack Block Kit 有十幾種 block types,但我只需要三種:
export const KnownBlockSchema = z.union([
HeaderBlockSchema, // 大標題
SectionBlockSchema, // 內容區塊
RichTextBlockSchema, // 格式化文字 (最主要是使用其中的 list)
]);
為什麼只要三種?
因為這三種就能組合出 90% 的排版需求:
省下來的 token 可以讓 AI 有更多 context。
比起把整個 Slack Block Kit schema(幾千個 token)塞進去,這樣的設計:
完整的 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>;
關鍵洞察:JSON Schema 不保證每次的輸出都沒失誤。
LLM 有時候會:
所以我在 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 的組合拳:
就像教小孩寫作文:
這個範例涵蓋了所有三種 block types,而且展示了:
plain_text
格式mrkdwn
語法(粗體、刪除線、連結、emoji)有了這個範例,AI 出錯的機率大幅下降。
成果展示*
注意看每個 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 輸出:
{
"text": "系統一切正常",
"blocks": [
{
"type": "section",
"text": {
"type": "mrkdwn",
"text": "✅ 系統一切正常,所有服務運作順暢。"
}
}
]
}
注意:
使用者問: 「分析這個 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": " 增加監控告警"
}
]
}
]
}
]
}
]
}
注意:
這些都是 AI 自己決定的,我沒有在 template 裡寫死。
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
---
有趣的是,雖然我讓 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 自己排版。
這個設計帶來什麼好處?
AI 可以根據內容:
每次輸出都是「剛剛好」,而不是「一體適用」。
想要調整風格?改 prompt:
# 想要更正式的風格
Be professional and avoid using emojis unless necessary.
# 想要更活潑的風格
Use appropriate emojis to make the message more engaging.
不用改 code,不用測試所有情境。
用 Zod + TypeScript 確保輸出一定符合 Slack API 規範:
export type MessageContentsType = z.infer<typeof MessageContentsSchema>;
VSCode 的 IntelliSense 會提示你可以用哪些欄位,編譯時期就能抓出問題。
今天我們學到了「放手」的藝術:
核心觀念:
設計原則:
實際效益:
哲學思考:
「給工具,不給限制。」
「相信 AI 比你更懂排版。」
AI 的發展變化很快,目前這個想法以及專案也還在實驗中。但也許透過這個過程大家可以有一些經驗和想法互相交流,歡迎大家追蹤這個系列。
也歡迎追蹤我的 Threads @debuguy.dev