嗨大家,我是 Debuguy。
昨天我們聊完了 Prompt 管理,今天要來解決一個更根本的問題:為什麼我的 ChatBot 每次都像失憶一樣?
想像一下這個場景:
👤 Debuguy:
@bot
幫我查一下我們 API 的 authentication 文件🤖 Bot:
@Debuguy
這是我們的 API authentication 文件連結:https://docs.company.com/auth👤 Debuguy:
@bot
那 JWT token 的有效期限是多久?🤖 Bot:
@Debuguy
不好意思,我不知道你指的是哪個 JWT token,請提供更多資訊。
「蛤?我剛剛才問過 API 的事情,你馬上就忘了?」
這就是典型的單輪對話問題。每次 Bot 收到新訊息時,它完全不記得之前發生過什麼事,就像每次見面都是第一次一樣。
讓我們回顧一下昨天的程式碼:
const chatFlow = ai.defineFlow(
{
name: 'chatFlow',
inputSchema: z.object({
text: z.string(),
user: z.string(),
ts: z.string(),
}),
},
async ({ user, ts, text }) => (await ai.prompt('chatbot')({
botUserId: process.env['SLACK_BOT_USER_ID']!,
prompt: `${user}@${ts}: ${text}`,
})).text
);
問題一目了然:我只把「當前這一則訊息」丟給 LLM,完全沒有提供任何歷史脈絡。
對 LLM 來說,每次收到的都是:
U1234567890@1699123456.123456: 那 JWT token 的有效期限是多久?
它怎麼可能知道你在問的是哪個 API 的 JWT token 呢?
一般來說,要實現多輪對話,標準做法是:
1. 設計資料庫 Schema
// conversations collection
{
_id: ObjectId,
user_id: String,
channel_id: String,
thread_id: String,
created_at: Date
}
// messages collection
{
_id: ObjectId,
conversation_id: ObjectId,
role: String, // 'user' or 'model'
content: String,
timestamp: Date
}
2. 實作對話管理邏輯
// 取得歷史對話
const conversation = await getOrCreateConversation(userId, channelId, threadId);
const history = await db.collection('messages')
.find({ conversation_id: conversation._id })
.sort({ timestamp: 1 })
.toArray();
// 建構完整的對話脈絡
const messages = [
{ role: 'system', content: systemPrompt },
...history.map(msg => ({ role: msg.role, content: msg.content })),
{ role: 'user', content: currentMessage }
];
// 儲存新訊息
await db.collection('messages').insertOne({
conversation_id: conversation._id,
role: 'user',
content: currentMessage,
timestamp: new Date()
});
const response = await generateResponse(messages);
await db.collection('messages').insertOne({
conversation_id: conversation._id,
role: 'assistant',
content: response,
timestamp: new Date()
});
3. 處理各種 Edge Cases
「哇,光是想到要寫這些程式碼就頭痛了...」
(雖然現在都是讓 AI 寫就是了 😜)
就在我準備開始設計資料庫 Schema 的時候,突然想到一個問題:
「等等,Slack 的 Thread 本身不就是一個對話串嗎?」
仔細想想,Slack Thread 具備了所有我需要的特性:
1. 天然的對話脈絡
2. 免費的儲存服務
3. 完整的 API 支援
thread_ts
取得整個 Thread 的訊息「這根本就是最被低估的資料庫啊!」
看到這裡,你的第一個想法是不是覺得,只要把 Thread 中的訊息按時間順序排列,然後一股腦丟給 LLM 就可以了?
U1234567890@1699123456.123456: 幫我查一下 API 文檔
assistant@1699123460.654321: 這是 API 文檔連結:https://docs.company.com/auth
U1234567890@1699123465.789012: 那 JWT token 的有效期限是多久?
我一開始的想法不是這樣。原因在於現代 LLM 本身就是基於對話格式設計的,它們定義了明確的 role
概念(最基本的就是 user
、model
),並且使用交錯的格式進行對話。
因此我第一個想法不是直接丟字串進去,而是要「偽造」LLM 的對話歷史,利用 LLM 本身熟悉的格式,讓它能「原地理解」對話脈絡。
現代 LLM 都是基於 messages
格式訓練的:
[
{ role: 'user', content: [...] },
{ role: 'model', content: [...] },
{ role: 'user', content: [...] }
]
將 Slack Thread 轉為標準 LLM 對話格式後,LLM 就不需要額外的算力去解析「我(LLM)之前說了什麼」,能專注於理解對話內容和生成回應。
看看我實際的 organizeMessages
函數:
function organizeMessages(
messages: { text: string; user: string; ts: string }[]
): { newMessages: string[], history: { role: 'user' | 'model', content: { text: string }[] }[] } {
const groups = messages.reduce((g, m) => {
const lastGroup = g[g.length - 1];
const role = m.user === process.env['SLACK_BOT_USER_ID'] ? 'model' : 'user';
m.text = role === 'user' ? `${m.user}@${m.ts}: ${m.text}` : m.text
if (lastGroup !== undefined && lastGroup.role === role) {
lastGroup.content.push({
text: m.text
});
} else {
g.push({
role: role,
content: [{ text: m.text }]
});
}
return g;
}, [] as { role: 'user' | 'model', content: { text: string }[] }[]);
return {
newMessages: groups.pop()!.content.map(c => c.text),
history: groups,
};
}
這個函數的關鍵設計:
const role = m.user === process.env['SLACK_BOT_USER_ID'] ? 'model' : 'user';
透過比對 user ID,自動識別訊息是來自 Bot 還是真人用戶。
if (lastGroup !== undefined && lastGroup.role === role) {
lastGroup.content.push({ text: m.text });
} else {
g.push({ role: role, content: [{ text: m.text }] });
}
這個邏輯處理了一個重要的現實情況:連續訊息的合併。
想像這個 Slack Thread:
👤 Debuguy: @bot 幫我查一下 API 文檔
👤 Debuguy: 特別是 authentication 的部分
🤖 Bot: 好的,讓我幫你找找
🤖 Bot: 這是 API authentication 文檔連結:https://docs.company.com/auth
🤖 Bot: 裡面有詳細的使用說明
👤 Debuguy: JWT token 的有效期限呢?
如果不做分組,LLM 會看到:
[
{ role: 'user', content: 'U1234@123: 幫我查一下 API 文檔' },
{ role: 'user', content: 'U1234@124: 特別是 authentication 的部分' },
{ role: 'model', content: '好的,讓我幫你找找' },
{ role: 'model', content: '這是 API authentication 文檔連結...' },
{ role: 'model', content: '裡面有詳細的使用說明' },
{ role: 'user', content: 'U1234@125: JWT token 的有效期限呢?' }
]
根據實戰經驗,有些 LLM 的 API 是不允許同一個 role 連續出現的,必須嚴格交錯。
但經過 organizeMessages
處理後:
[
{
role: 'user',
content: [
{ text: 'U1234@123: 幫我查一下 API 文檔' },
{ text: 'U1234@124: 特別是 authentication 的部分' }
]
},
{
role: 'model',
content: [
{ text: '好的,讓我幫你找找' },
{ text: '這是 API authentication 文檔連結...' },
{ text: '裡面有詳細的使用說明' }
]
}
]
現在 LLM 能清楚理解:用戶說了一輪話(包含兩個相關的請求),然後 Bot 回了一輪話(包含三個相關的回應)。
return {
newMessages: groups.pop()!.content.map(c => c.text),
history: groups,
};
這是一個特別的設計:把最後一組訊息從歷史中分離出來,作為「當前要處理的新訊息(prompt)」,而前面的部分作為「對話歷史」。
async function formatMessages(event: AppMentionEvent, client: WebClient): Promise<{ text: string; user: string; ts: string; }[]> {
if (event.thread_ts) {
const threadReplies = await client.conversations.replies({
channel: event.channel,
ts: event.thread_ts,
});
if (threadReplies.ok && threadReplies.messages) {
return threadReplies.messages
.filter((message) => Boolean(message.text && message.user && message.ts))
.map((message) => ({ text: message.text!, user: message.user!, ts: message.ts! }));
}
}
// 如果沒有 thread / reply,就只處理當前訊息
if (event.text && event.user && event.ts) {
return [{ text: event.text, user: event.user, ts: event.ts }];
}
return [];
}
這個函數負責:
thread_ts
參數){ text, user, ts }
格式,方便後續處理const chatFlow = ai.defineFlow({
name: 'chatFlow',
inputSchema: z.object({
messages: z.array(z.object({
text: z.string(),
user: z.string(),
ts: z.string(),
})),
}),
},
async ({ messages }) => {
const { newMessages, history } = organizeMessages(messages);
return (await ai.prompt('chatbot')({
botUserId: process.env['SLACK_BOT_USER_ID']!,
prompt: newMessages,
}, {
messages: history // 這裡傳入標準化的對話歷史
})).text;
}
);
這個設計的精妙之處:
organizeMessages
專門處理訊息分組,Flow 專注於業務邏輯messages
格式async function startSlackBolt() {
const app = new slackBolt.App({
token: process.env['SLACK_BOT_TOKEN']!,
appToken: process.env['SLACK_APP_TOKEN']!,
socketMode: true,
});
app.event('app_mention', async ({ event, say, client }) => {
const messages = await formatMessages(event, client);
const response = await runFlow({
url: 'http://127.0.0.1:3400/chatFlow',
input: {
messages,
}
});
await say({ text: response, thread_ts: event.thread_ts || event.ts });
});
await app.start();
}
對應的 chatbot.prompt
檔案也需要調整:
---
model: googleai/gemini-2.5-flash-lite
config:
temperature: 0.2
topP: 0.95
topK: 30
thinkingConfig:
includeThoughts: true
thinkingBudget: -1
input:
schema:
botUserId: string
prompt(array): string
---
{{role "system"}}
You are a helpful AI assistant operating as a Slack bot.
Your Slack ID is {{botUserId}}.
The user who mentioned you is the one you must address in your reply.
You will receive a message from a user in a Slack channel. format is <USER_ID>@<TIMESTAMP>: <MESSAGE>.
**Your Response Style:**
* Be helpful, friendly, and conversational.
* When you finish your response, tag the user who originally mentioned you using their User ID, the format is <@USER_ID>.
* Always maintain a natural, human-like tone while being clear that you're an AI assistant.
Remember: You are not just executing commands; you are participating in conversations as a helpful team member.
{{role "user"}}
{{#each prompt}}
{{this}}
{{/each}}
為什麼 prompt 要用陣列?
因為 newMessages
可能包含多條連續的訊息:
// 當用戶連續發送多條訊息時
newMessages = [
"U1234@123: 幫我查一下 API 文檔",
"U1234@124: 特別是 authentication 的部分"
]
使用 Handlebars 的 {{#each}}
語法,可以把這些訊息都正確組合成一個 user role 的內容。
👤 Debuguy:
@bot
幫我查一下 API authentication 文檔🤖 Bot:
@Debuguy
這是文檔連結...👤 Debuguy:
@bot
JWT token 的有效期限呢?🤖 Bot:
@Debuguy
請問你指的是哪個 JWT token?
👤 Debuguy:
@bot
幫我查一下 API authentication 文檔🤖 Bot:
@Debuguy
這是我們的 API authentication 文檔連結:https://docs.company.com/auth👤 Debuguy: JWT token 的有效期限呢?
🤖 Bot:
@Debuguy
根據剛才提到的 API authentication 文檔,JWT token 的預設有效期限是 24 小時。你也可以在請求時透過expires_in
參數自訂有效時間,最長不超過 7 天。
感受到差異了嗎?Bot 現在能:
LLM 收到: "U1@1: 查API文檔\nbot: 這裡是連結\nU1@2: JWT有效期?"
LLM 需要自己解析這個混亂的格式,浪費算力。
1. 資料庫維護零成本
2. 效能優化外包給 Slack
3. 資料生命週期管理
「當我意識到不用自己寫資料庫時的心情,就像在外套口袋找到 200 塊一樣開心!」
天然的對話體驗
透明的對話歷史
當 Thread 中有多個人參與時,對話會變得更複雜。這個我們明天會深入討論。
長對話可能會超過 LLM 的 context window,需要考慮歷史訊息的截斷策略。
每次都要呼叫 Slack API 取得 Thread 歷史,在高頻使用時可能需要考慮快取機制。
這次的解決方案證明了一個重要的設計原則:
善用現有的基礎設施,而不是重新發明輪子
Slack Thread 作為對話容器帶來的好處:
透過智慧型的訊息分組設計,我們實現了:
當然,這個方法不見得適用於所有場景,但對於 Slack ChatBot 來說,它是一個相當優雅的解決方案。
「有時候最好的技術決策,就是少寫一些程式碼」
明天我們來面對更大的挑戰:多人對話。當你的 Thread 中不只有你和 Bot,還有其他同事參與時,事情會變得更有趣(也更複雜)。
完整的原始碼在這裡,記得試試看多輪對話的效果!
AI 的發展變化很快,目前這個想法以及專案也還在實驗中。但也許透過這個過程大家可以有一些經驗和想法互相交流,歡迎大家追蹤這個系列。
也歡迎追蹤我的 Threads @debuguy.dev