iT邦幫忙

2025 iThome 鐵人賽

DAY 5
0
生成式 AI

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

Day 5: 從 Bot 到 ChatBot - 多輪對話的實現之路

  • 分享至 

  • xImage
  •  

嗨大家,我是 Debuguy。

昨天我們聊完了 Prompt 管理,今天要來解決一個更根本的問題:為什麼我的 ChatBot 每次都像失憶一樣?

失憶 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 呢?

傳統解決方案 vs. 我的偷懶哲學

標準做法:自建對話狀態管理

一般來說,要實現多輪對話,標準做法是:

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

  • 對話過期的清理機制
  • Token 超限時的歷史截斷
  • 多用戶同時對話的競態條件
  • 資料庫連線失效的錯誤處理

「哇,光是想到要寫這些程式碼就頭痛了...」
(雖然現在都是讓 AI 寫就是了 😜)

靈光乍現:Slack Thread 就是天然的資料庫

就在我準備開始設計資料庫 Schema 的時候,突然想到一個問題:

「等等,Slack 的 Thread 本身不就是一個對話串嗎?」

仔細想想,Slack Thread 具備了所有我需要的特性:

1. 天然的對話脈絡

  • Thread 中的每則訊息都有時間順序
  • 可以清楚區分是誰說的話
  • 使用者可以直接看到完整對話歷史

2. 免費的儲存服務

  • Slack 幫我們保存所有訊息
  • 不用擔心資料備份和災難復原
  • 不用自己處理資料清理

3. 完整的 API 支援

  • 可以透過 thread_ts 取得整個 Thread 的訊息
  • 訊息有完整的 metadata(發話者、時間戳記等)

「這根本就是最被低估的資料庫啊!」

技術實現:把 Slack Thread 作為 context 傳給 LLM

關鍵問題:如何正確建構 LLM 的對話歷史?

看到這裡,你的第一個想法是不是覺得,只要把 Thread 中的訊息按時間順序排列,然後一股腦丟給 LLM 就可以了?

U1234567890@1699123456.123456: 幫我查一下 API 文檔
assistant@1699123460.654321: 這是 API 文檔連結:https://docs.company.com/auth
U1234567890@1699123465.789012: 那 JWT token 的有效期限是多久?

我一開始的想法不是這樣。原因在於現代 LLM 本身就是基於對話格式設計的,它們定義了明確的 role 概念(最基本的就是 usermodel),並且使用交錯的格式進行對話。

因此我第一個想法不是直接丟字串進去,而是要「偽造」LLM 的對話歷史,利用 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,
  };
}

這個函數的關鍵設計:

1. 自動角色識別

const role = m.user === process.env['SLACK_BOT_USER_ID'] ? 'model' : 'user';

透過比對 user ID,自動識別訊息是來自 Bot 還是真人用戶。

2. 智慧型連續訊息分組

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 回了一輪話(包含三個相關的回應)。

3. 當前訊息的特殊處理

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 [];
}

這個函數負責:

  1. 取得完整 Thread 歷史(透過 thread_ts 參數)
  2. 資料清理:過濾掉不完整的訊息
  3. 格式統一:轉成統一的 { text, user, ts } 格式,方便後續處理

GenKit Flow 的優雅整合

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;
  }
);

這個設計的精妙之處:

  1. 輸入驗證:使用 Zod schema 確保資料格式正確
  2. 責任分離organizeMessages 專門處理訊息分組,Flow 專注於業務邏輯
  3. 標準化介面:向 GenKit 提供標準的 messages 格式

完整的 Slack App 整合

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();
}

dotPrompt 的相應調整

對應的 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 的內容。

實際效果:從失憶到有記憶

Before(單輪對話的窘境)

👤 Debuguy: @bot 幫我查一下 API authentication 文檔

🤖 Bot: @Debuguy 這是文檔連結...

👤 Debuguy: @bot JWT token 的有效期限呢?

🤖 Bot: @Debuguy 請問你指的是哪個 JWT token?

After(多輪對話的流暢)

👤 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 現在能:

  • 記住對話脈絡:知道在討論 API authentication
  • 理解代詞指向:「JWT token」明確指向 API 的 JWT token
  • 提供精準回答:基於之前的對話內容給出相關建議

技術效果對比

Before(簡單字串拼接)

LLM 收到: "U1@1: 查API文檔\nbot: 這裡是連結\nU1@2: JWT有效期?"

LLM 需要自己解析這個混亂的格式,浪費算力。

After(標準化對話格式)

意外收穫:不用自己管資料庫(謝謝 Slack!)

省下的麻煩事

1. 資料庫維護零成本

  • 不用設計 Schema
  • 不用處理資料遷移
  • 不用擔心硬碟空間和備份

2. 效能優化外包給 Slack

  • 不用設計索引和查詢優化
  • 不用處理高並發讀寫
  • 依賴 Slack API 的穩定性(通常比我們自己寫的更可靠)

3. 資料生命週期管理

  • Slack 有自己的資料保存政策
  • 不用寫 cronjob 清理舊對話
  • 使用者可以自己刪除不要的 Thread

「當我意識到不用自己寫資料庫時的心情,就像在外套口袋找到 200 塊一樣開心!」

使用者體驗的提升

天然的對話體驗

  • 符合 Slack 使用者的既有習慣
  • Thread 本來就是用來進行主題討論的
  • 不用學習新的對話模式或切換介面

透明的對話歷史

  • 使用者可以直接在 Slack 中檢視完整對話
  • 可以輕鬆分享 Thread 連結給其他同事
  • 對話脈絡一目了然,便於後續追蹤

未來的挑戰

多人對話的複雜性

當 Thread 中有多個人參與時,對話會變得更複雜。這個我們明天會深入討論。

Token 限制的考量

長對話可能會超過 LLM 的 context window,需要考慮歷史訊息的截斷策略。

API 成本管控

每次都要呼叫 Slack API 取得 Thread 歷史,在高頻使用時可能需要考慮快取機制。

小結:偷懶偷出新高度

這次的解決方案證明了一個重要的設計原則:

善用現有的基礎設施,而不是重新發明輪子

Slack Thread 作為對話容器帶來的好處:

  • 零維護成本的資料儲存解決方案
  • 天然整合的使用者體驗
  • 完整生態系的 API 支援

透過智慧型的訊息分組設計,我們實現了:

  • 提升 LLM 理解品質:標準化的對話格式讓 LLM 專注於內容
  • 處理複雜場景:連續訊息的自動合併和角色識別
  • 保持程式碼簡潔:責任分離和模組化的架構設計

當然,這個方法不見得適用於所有場景,但對於 Slack ChatBot 來說,它是一個相當優雅的解決方案。

「有時候最好的技術決策,就是少寫一些程式碼」

明天我們來面對更大的挑戰:多人對話。當你的 Thread 中不只有你和 Bot,還有其他同事參與時,事情會變得更有趣(也更複雜)。


完整的原始碼在這裡,記得試試看多輪對話的效果!


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

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


上一篇
Day 4: 別讓 Prompt 變成你的技術債 - GenKit dotPrompt 的版本控制方法
下一篇
Day 6: 從一對一到多人對話 - 當你的 Thread 變成群聊現場
系列文
AI 產品與架構設計之旅:從 0 到 1,再到 Day 29
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言