CareerWise 從強制注入到 Tool Use:逐步重構 LLM 知識庫搜尋的真實案例

本文記錄 CareerWise 職涯諮詢機器人逐步從強制注入走向 Tool Use 的過程。CareerWise 是一個以 LLM 為核心的職涯諮詢的網站,提供履歷健檢、職涯建議與預約諮詢的服務,使用者可以詢問轉職、面試、薪資談判等問題,系統則透過知識庫搭配 LLM 來提供 grounded 的建議。本次核心的轉變是把「程式碼強制決定什麼時候查知識庫」改成「讓 LLM 自己決定什麼時候查」。實作牽涉到 streaming + tool calling 的整合、多個邊界情況的處理,以及一些設計取捨。

重構前:強制注入

CareerWise 的路由系統

CareerWise 收到使用者訊息後,第一步經由路由層(src/lib/router.ts)將問題分類為四種意圖:

type Intent = "greeting" | "career" | "resume" | "booking"

每種意圖對應的處理方式完全不同:

Intent 範例輸入 是否觸發 searchKnowledge
greeting 「你好」、「嗨」、「早安」
career 「怎麼談薪水」、「該不該轉職」、「面試怎麼準備」 (本工具唯一綁定的 intent)
resume 「幫我看履歷」、「CV 要怎麼改」 否(走 resume chain)
booking 「我想預約」、「可以諮詢嗎」 否(直接回傳表單連結)

為什麼只有 career 需要知識庫?

Intent Handler 行為 為什麼不需要知識庫?
greeting greetingHandler 80 tokens 打招呼,不帶知識庫 打招呼是社交禮儀,不需要參考任何資料。LLM 在預訓練階段就看過數十億筆社交對話,回一句「你好!我是 Summer,有什麼職涯問題想問的嗎?」對它來說輕而易舉。多花 200ms 去查知識庫,回來的 chunk 也不會讓招呼打得更好。
career careerHandler 職涯建議(轉職/面試/薪資/學習路線) 需要知識庫。Summer 的職涯觀點、具體案例、市場分析都寫在知識庫文章中,LLM 的通用知識不夠具體。
resume resumeHandler 呼叫 runResumeChain() 走專屬 chain 履歷分析是高度結構化的多步驟流程。runResumeChain() 內部已是精密的 3-step pipeline:第一步從使用者輸入中提取結構化 JSON(職位、年資、技能等),第二步用這些結構化欄位去 searchSimilar 查知識庫,第三步產生報告 + 品質反思。如果硬改成 tool calling,反而會破壞 chain 內部精心設計的欄位提取邏輯。
booking bookingHandler 直接回傳預約表單連結,不走 LLM 靜態回應,零 token 成本。

總結:greeting 太簡單不需要、resume 走獨立 chain 不需要、booking 不走 LLM 不需要。只有 career 仰賴知識庫內容來提供具體、 grounded 的職涯建議,因此 searchKnowledge 工具只掛載在 career 路徑上。

強制注入的運作方式

重構前,只要是 intent === "career",程式碼就強制執行知識庫搜尋,不管 LLM 想不想要、不管問題需不需要

以下三個真實案例說明這個問題:

範例一:常識性問題(不該搜)

使用者: 「面試要穿什麼?」
知識庫: 存放的是 Summer 的職涯觀點、市場分析、轉職策略
           → 知識庫裡根本不會有服儀建議
           → 但程式碼還是強制搜,浪費 ~500ms
LLM:   面試服儀這種常識問題,我預訓練時就看過幾百萬篇文章了,我本來就會回答。為什麼要塞一堆跟服儀無關的職涯 chunk 給我?我還要花 token 去讀它們,而且 system prompt 還叫我「不可忽略」。

範例二:LLM 自身知識已足夠

使用者: 「轉職前端的學習路線?」
知識庫: 回傳 chunk「2025 軟體工程師生存指南」中的片段
LLM:   我自己就知道 HTML → CSS → JavaScript → React 這條路
        但 system prompt 要求「不可忽略」,我只能硬著頭皮引用 chunk

範例三:LLM 常識即可回答,知識庫反而沒有對應內容

使用者: 「寫程式 30 歲以後還行嗎?」
知識庫: 這是一個 career 問題,路由層會正確分到 career intent。但這個問題涉及年齡與程式職涯的普遍迷思——知識庫存放的是 Summer 的具體職涯觀點、市場分析、轉職策略等內容,不一定有針對「30 歲寫程式」的專門文章。embedding 搜尋回傳的 chunk 可能只是勉強相關的片段。
LLM:   30 歲寫程式這問題,我在訓練資料裡看過好幾百次了——年齡不是問題、經驗是資產、國外有很多大齡工程師的例子,這些我都知道。結果程式碼還是強制去知識庫搜了一大段不相干的 chunk 塞給我,還叫我「不可忽略」。

這些範例點出強制注入的核心問題:程式碼沒有能力判斷「這個問題需不需要知識庫」,只能無差別地對所有 career 問題執行搜尋,浪費延遲與 token——而且 chunk 跟問題本身根本對不上。

重構前的程式碼

這是重構前的強制注入邏輯,在 chat.ts 中:

// chat.ts 重構前 — 強制注入邏輯

// 第 67 行:handler.shouldSearchKnowledge 為 true 就強制搜
const knowledge = handler.shouldSearchKnowledge
  ? await getKnowledge(message)
  : ""

// getKnowledge 內部:直接用使用者訊息全文當 query
async function getKnowledge(message: string): Promise<string> {
  const similar = (await searchSimilar(message)).join("\n\n")
  return similar || loadAllKnowledge()  // ← 搜不到就整本知識庫丟進去!
}

// 第 35 行:系統訊息注入 — 不需 LLM 同意
messages.push({
  role: "system",
  content: handler.getSystemPrompt(knowledge),
  //  ↑ LLM 眼前突然多了一段「知識庫:...」
})

// career handler 甚至要求:
// 「【重要】你必須根據以下知識庫內容來回答,不可忽略或自行編造」
//  ↑ LLM 被強迫使用,沒有選擇權

注意第 91 行:如果 searchSimilar() 回傳空陣列(搜不到相關 chunk),它不會留空——而是直接 loadAllKnowledge() 將整本知識庫全部丟進去。

完整的流程如下:

  1. 使用者說「怎麼談薪水?」
  2. 程式碼直接拿「怎麼談薪水?」當 query 去 searchSimilar()
  3. chunk 硬塞進 system prompt 的「知識庫:」區塊
  4. LLM 根本不知道有搜尋這回事,眼前就突然多了一段文字
  5. system prompt 還要求「不可忽略或自行編造」— 強迫 LLM 使用

Career handler 的 system prompt:

// 重構前 career handler 的 system prompt
getSystemPrompt(knowledge: string): string {
  return `...【重要】你必須根據以下知識庫內容來回答,不可忽略或自行編造
知識庫:
${knowledge || "(目前知識庫無相關內容,請根據你的專業知識回答)"}`
}

這等同於「把書翻開到固定頁碼,強迫 LLM 閱讀」。LLM 沒有選擇權。

重構後:Tool Use(LLM 自主決策)

Step 1:工具定義(告訴 LLM 有什麼可用)

src/lib/tools/search-knowledge.ts 定義一個工具:

export const SEARCH_KNOWLEDGE_TOOL = {
  type: "function",
  function: {
    name: "searchKnowledge",
    description: "搜尋 Summer 的職涯知識庫,取得特定主題的參考資料",
    parameters: {
      type: "object",
      properties: {
        query: {
          type: "string",
          description: "搜尋查詢字串,例如「薪資談判 技巧」",
        },
      },
      required: ["query"],
    },
  },
}

這份 JSON schema 告訴 LLM:廚房裡有一台叫 searchKnowledge 的工具,它的用途是搜尋知識庫,需要帶入一個 query 參數。

這個工具定義只掛載在 career 路徑上(chat.ts 第 50 行):

const tools = intent === "career" ? [SEARCH_KNOWLEDGE_TOOL] : undefined
// greeting / resume / booking 不走工具

Step 2:LLM 決策(自主判斷要不要查)

重構後,career handler 的 system prompt 不再注入 chunk,而是告訴 LLM 有工具可用:

// 重構後 career handler 的 system prompt
getSystemPrompt(): string {
  return `...當你需要特定資訊時,使用 searchKnowledge 工具查詢知識庫。
如果知識庫沒有相關資料,根據你的專業知識回答,不要憑空編造。`
}

重構前的 prompt 說「不可忽略或自行編造」——這是命令。重構後的 prompt 說「當你需要時使用」——這是授權。

LLM 收到使用者問題後,開始自主推理:

使用者:「怎麼談薪水?」

LLM 內部推理:
  「薪資談判是我可以回答的主題,但我需要更具體的 Summer 觀點和案例。
   知識庫裡可能有相關文章,我應該呼叫 searchKnowledge 查查看。」

→ 輸出 JSON 點單:
  {
    tool: "searchKnowledge",
    arguments: { query: "薪水談判技巧" }
  }

如果 LLM 判斷不需要知識庫(例如常識性問題),它也可以選擇不呼叫工具,直接用自己的知識回答。這就是 Tool Use 的核心——LLM 擁有選擇權。

Step 3:框架執行(二廚接手)

chat.ts 收到 LLM 的 tool_call 後,接手執行:

1. 解析 tool_call,取出 query = "薪水談判技巧"
2. 執行 searchKnowledge("薪水談判技巧")
   → 內部呼叫 searchSimilar(),回傳知識庫 chunk
3. 將結果以 tool response 回傳給 LLM

LLM 收到結果後,產出 grounded 回應:
「根據我的知識庫,談薪水有幾個關鍵技巧:第一,了解市場行情…」

如果 LLM 決定不呼叫工具(finish_reason === "stop"),則直接回傳文字,完全不走搜尋流程——零浪費。

Before vs After 對照

User: 「怎麼談薪水?」

                  ┌──────────────────┐
Before (強制注入)  │  程式碼決定搜尋    │
                  │  searchSimilar    │
                  │  ("怎麼談薪水?")  │
                  │  → chunk 塞入     │
                  │  → LLM 被動接收   │
                  └──────────────────┘

                  ┌──────────────────┐
After (Tool Use)  │  LLM 自主決定     │
                  │  → 輸出 tool_call │
                  │  → searchKnowledge│
                  │  ("薪水談判技巧")  │
                  │  → LLM 主動使用   │
                  └──────────────────┘

本質差異:

  強制注入(Before) Tool Use(After)
誰決定搜尋? 程式碼(handler.shouldSearchKnowledge LLM
誰決定 query? 程式碼(直接把 message 當 query) LLM(精準下關鍵字)
LLM 知道知識庫的存在嗎? 不知道,chunk 突然出現在 prompt 中 知道,LLM 主動呼叫工具
LLM 可以不使用知識庫嗎? 不行,system prompt 要求「不可忽略」 可以,LLM 自行判斷
對應文章步驟 工具定義 → LLM 決策 → 框架執行

重構後的流程與〈打破文字監獄〉中提到的三步驟完全吻合:

文章提到的步驟 實作對應
工具定義(宣告階段) SEARCH_KNOWLEDGE_TOOL schema,掛載在 career 路徑
LLM 決策(結構化輸出) LLM 輸出 tool_call JSON,指定工具與參數
框架執行(二廚接手) chat.ts 解析 tool_call,執行 searchKnowledge(),回傳結果

選 streaming 而非 full-response 對 UI/UX 的影響

Streaming + Tool Call 的實作路線

整個流程分為三個階段。原因是 LLM 模型(Groq 的 llama-3.1-8b-instant)的 API 是無狀態的——每次 groq.chat.completions.create() 都是獨立的事件,LLM 無法「自己查完工具繼續講」。

Phase 1:   User → LLM           LLM 決定要不要查
Phase 2:   程式碼執行工具        LLM 不在場
Phase 3:   工具結果 → LLM        LLM 根據結果回答

對比人類對話:如果一個人說「我查一下」,他查完可以自己接續話題。但 LLM 不行——它說完「我查一下」就結束了(Phase 1 finish),必須由程式碼去查(Phase 2),再把結果交給它讓它繼續說(Phase 3)。

User 送出「怎麼談薪水?」
  │
  ▼
Phase 1 — LLM streaming(帶 tools 參數)
  │
  ├─ [路徑 A] LLM 決定呼叫工具
  │    LLM 分析問題後,判斷需要查知識庫
  │    透過 stream 輸出 tool_call JSON:
  │      { tool: "searchKnowledge", args: { query: "薪水談判技巧" } }
  │    → stream 中出現 content: null + tool_calls 欄位
  │    → finish_reason 為 "tool_calls",Phase 1 結束
  │    → 此時使用者還沒看到任何文字(content 從頭到尾都是 null)
  │
  └─ [路徑 B] LLM 決定直接用自身知識回答
       LLM 判斷不需要查知識庫
       → 正常串流文字直到 finish_reason = "stop"
       → 完全等同重構前的行為,不經工具
  │
  ▼
Phase 2 — 執行工具(僅路徑 A)
  chat.ts 解析 tool_call:
    1. 取出 arguments.query = "薪水談判技巧"
    2. 呼叫 searchKnowledge("薪水談判技巧")
       → searchSimilar() → Hugging Face embedding → 回傳 chunk
    3. 回傳結果給 LLM
  │
  ▼
Phase 3 — 第二次 LLM streaming(僅路徑 A,帶 tool result)
  chat.ts 將 tool result 以 { role: "tool", content: chunk } 加入對話
  第二次 LLM 呼叫開始串流
  
  LLM 根據 chunk 產出 grounded 回應:
  「根據我的知識庫,談薪水有幾個關鍵技巧:第一,市場調查…」
  
  → 使用者真正看到逐字顯示
  → 每個字都有知識庫作為依據

什麼是 grounded?

Grounded(接地、有根據)指的是 LLM 的回答不是憑空生成,而是有外部資料作為依據。

  無 grounded(純 LLM 知識) 有 grounded(工具查詢後)
使用者問 「前端工程師在台灣好找工作嗎?」 同上
LLM 回答 「前端工程師在台灣需求穩定,React 和 Vue 的職缺很多。」 「根據我的知識庫,前端工程師在台灣的就業市場需求穩定,其中 React 和 Vue 等框架的職缺最多。」
問題 LLM 只是從訓練資料中提取通用知識,無法指向具體來源,也無法保證資訊時效性。 回答有知識庫 chunk 作為依據,每個 claim 都可以追溯到來源文章。

Grounded 的價值在於:使用者可以信任回答的內容,因為它不是 LLM 在訓練階段看到的大量網路文章的平均值,而是 Summer 親自整理的具體觀點。

使用者感受

路徑 A(呼叫工具):使用者送出問題後,會有一個極短暫的停頓(約 0.3-1s)。這個停頓包含:第一次 LLM 串流(Phase 1,contentnull,使用者無感)+ 知識庫搜尋(Phase 2,約 200ms)+ 第二次 LLM 啟始延遲。停頓過後,文字開始逐字出現——而且這些文字是 grounded 的,背後有知識庫內容支撐。

路徑 B(直接回答):使用者送出後,文字立刻開始逐字出現。完全等同重構前的體驗,不經工具、不需要額外的 round-trip。

關鍵差別:重構前,所有 career 問題都必須等知識庫搜尋完才能開始串流(因為 chunk 要先塞進 system prompt)。重構後,只有 LLM 判斷「需要查」的問題才會等搜尋——如果 LLM 覺得自己的知識夠用,搜尋直接被跳過,使用者更快看到回應。

情境 感受
LLM 直接回答(不須查知識庫) 完全無感,跟現在一樣
LLM 查知識庫後回答 送出後約 0.3-1s 才開始顯示文字(搜尋 + 第二次 LLM),但顯示後就是 grounded 內容。
對比現狀 現在是「送出後等 loading → 字跑出來」,改成「送出後短暫停頓 → 字跑出來」,體驗更接近自然對話

為什麼不做 buffered

如果採用 buffered 方式:先完整跑完 Phase 1 + 2,確定 LLM 的意圖後再決定要不要顯示,那麼:

因此採用 progressive yield 策略:有文字就立刻輸出,如果 LLM 決定呼叫工具則在 Phase 1 只輸出 content: null(使用者看不到任何東西),等工具執行完才在 Phase 3 輸出真正的回應。

邊界情況

有極少數模型可能在生成文字後又決定呼叫工具,這時使用者會先看到文字又中斷。實測 llama-3.1-8b-instant 不會發生此情況(工具呼叫時 content 固定為 null)。若後續換模型有這問題,可再加入 buffer 前 N 個 chunk 的保護機制。

什麼是 buffer 前 N 個 chunk?

假設設定 N=3,表示前 3 個 streaming chunk 先不顯示給使用者,而是暫存在 buffer 中。如果第 4 個 chunk 出現 tool_calls,表示 LLM 決定呼叫工具——這時前 3 個 chunk 的內容可能是 LLM 邊思考邊產生的半成品文字。正確做法是:捨棄 buffer,進入 Phase 2 執行工具,等 Phase 3 再重新串流。

如果前 3 個 chunk 之後 LLM 都沒有呼叫工具(finish_reason 為 stop),則把 buffer 中的 3 個 chunk 一次輸出給使用者,繼續正常串流。

stream 開始 → buffer 前 N 個 chunk
              ├── 出現 tool_calls → 捨棄 buffer,進入 Phase 2
              └── N 個 chunk 後無 tool_calls → 輸出 buffer,繼續正常串流

N 的選擇是取捨:N 太小(如 1)可能 buffer 不足,還是會有半成品文字被顯示;N 太大(如 10)會增加使用者的等待時間。建議 N=3 或 N=5 作為起點。

此保護機制目前不需要,因為 llama-3.1-8b-instant 在呼叫工具時 content 固定為 null,不會產生半成品文字。保留此段落作為未來換模型時的參考。

架構變動

Before:
chat.ts → getKnowledge() → embed.ts → chunk → 強制注入 system prompt

After:
chat.ts → Phase 1: LLM streaming (with tools)
         → Phase 2: 執行 searchKnowledge(query)
         → Phase 3: 第二次 LLM streaming (with tool result)

新增檔案

src/lib/tools/search-knowledge.ts

工具定義(name、description、parameters)和執行函式。包裝現有 searchSimilar()

src/lib/tools/index.ts

工具註冊表,匯出工具定義陣列。

修改檔案

src/lib/chat.ts

src/lib/handlers/career.ts

修改 system prompt:不再注入預先搜尋的知識庫 chunk,改為指示 LLM 使用 searchKnowledge 工具。

測試方式

測試一:非串流(模擬 LINE Bot 路徑)

curl -X POST http://localhost:3000/api/chat \
  -H "Content-Type: application/json" \
  -d '{"message": "前端工程師在台灣好找工作嗎?", "history": []}'

預期行為:

檢查 SSE stream 中是否穿插 tool_calls 訊號:

curl -s -N -X POST http://localhost:3000/api/chat \
  -H "Content-Type: application/json" \
  -d '{"message": "前端工程師在台灣好找工作嗎?", "history": []}' \
  | head -20

測試二:直接測試 Groq Tool Calling

const Groq = require('groq-sdk');
const groq = new Groq({ apiKey: process.env.GROQ_API_KEY });

async function test() {
  const response = await groq.chat.completions.create({
    model: 'llama-3.1-8b-instant',
    messages: [
      { role: 'system', content: '使用 searchKnowledge 工具查詢知識庫。' },
      { role: 'user', content: '怎麼談薪水?' },
    ],
    tools: [{
      type: 'function',
      function: {
        name: 'searchKnowledge',
        description: '搜尋職涯知識庫',
        parameters: { type: 'object', properties: { query: { type: 'string' } }, required: ['query'] },
      },
    }],
    tool_choice: 'auto',
    max_tokens: 512,
  });
  const msg = response.choices[0].message;
  console.log('finish_reason:', response.choices[0].finish_reason);
  console.log('tool_calls:', JSON.stringify(msg.tool_calls, null, 2));
}
test();

預期輸出 finish_reason: tool_callstool_calls[0].function.name === "searchKnowledge"

測試三:本地啟動後用瀏覽器測試

npm run dev

開啟 http://localhost:3000 → 登入 → 在 chat 頁面輸入「怎麼談薪水?」或「前端轉職要注意什麼?」。

預期行為:

測試四:邊界案例

測試輸入 預期行為
「你好」 不觸發 tool call,直接串流打招呼
「我想預約諮詢」 Layer 1 規則命中,直接回傳表單連結(不走 LLM)
「該不該離職去新創」 classifyTier 判定 complex,走平行代理路徑(不經此工具)
「幫我看履歷」 handler.execute 路徑,走 resume chain

驗證結果(2026-07-14)

Groq API 工具呼叫支援測試

模型: llama-3.1-8b-instant
測試輸入: 「今天臺北天氣如何?」(搭配 get_weather 工具)
結果: ✅ finish_reason = tool_calls
     ✅ tool_calls[0].function.name = "get_weather"
     ✅ tool_calls[0].function.arguments = {"location":"臺北"}

兩階段串流測試

Phase 1 輸出:
  chunk 0: delta.content = null(角色指派,無文字)
  chunk 1: delta.tool_calls = [{ id, function: { name, arguments } }](工具呼叫)
  chunk 2: finish_reason = "tool_calls"(串流結束)

Phase 2 執行 searchKnowledge:
  query = "前端工程師在台灣找工作難易度"

Phase 3 第二次串流:
  輸出 grounded 回應文字

端到端兩階段呼叫測試

測試輸入: 「前端工程師在台灣好找工作嗎?」
Phase 1: ✅ LLM 自主呼叫 searchKnowledge({ query: "前端工程師在台灣的就業市場" })
Phase 2: ✅ 工具回傳 chunk(模擬)
Phase 3: ✅ 第二次 LLM 根據 chunk 產出 grounded 回應
         輸出:「根據知識庫資料,前端工程師在台灣的就業市場需求穩定…」

Greeting 不走工具測試

測試輸入: 「你好,最近好嗎?」
結果: ✅ 無 tool_calls,直接回應「我很好,謝謝你關心…」

實際生產環境測試(2026-07-14)

測試一:LLM 自行決定是否呼叫工具

測試輸入: 「怎麼談薪水?」
第一次: 461ms,無 tool_calls(LLM 用自己的知識回答)
第二次: 2.0s,觸發 tool_call ✅

Server log:
  [Tool Use][Stream] searchKnowledge query: 薪水談判技巧
  [Tool Use][Stream] searchKnowledge result length: 232
  POST /api/chat 200 in 2.0s

同樣的輸入「怎麼談薪水?」,兩次結果不同——第一次 LLM 直接回答,第二次 LLM 呼叫了工具。

這是因為 tool_choice: "auto" 模式下,LLM 自行判斷是否需要工具。這個判斷是非確定性的——「薪資談判」這個主題,LLM 的訓練資料本身就有大量相關內容,它第一次覺得夠用就直接回答;第二次同一個問題,它可能考慮到「這是 Summer 的職涯諮詢,使用者希望得到 Summer 的觀點」,於是決定查知識庫。

這不是 bug,而是 Tool Use 的核心行為:LLM 擁有選擇權,每次獨立判斷。重構前不論如何都強制注入 chunk;重構後 LLM 可以自由決定要不要查——第一次節省了 ~1.5s 和一次 API round-trip,第二次換取更 grounded 的回答。兩個結果都是正確的。

  強制注入(重構前) Tool Use(重構後)
LLM 知識夠用時 chunk 還是被強制注入,浪費 token 461ms 快速回答,不經工具
LLM 需要查時 chunk 還是被強制注入(但無法保證相關性) LLM 自主搜尋,精準下 query「薪水談判技巧」
浪費 每次都花 ~500ms 搜尋 + chunk 佔用 context window 需要才花,不需要就跳過

回頭看範例二:重構前 LLM 明明知道 HTML → CSS → JavaScript 這條路,但 system prompt 要求「不可忽略」,它只能硬著頭皮引用 chunk。重構後同樣情境下,LLM 會直接回答,零浪費。

測試二:所有路徑驗證

「你好」          → greeting path, 不經工具 ✅
「怎麼談薪水?」    → career simple path, 觸發 tool_call ✅
「2025 軟體工程師生存指南說了什麼?」
                   → career medium path, 走 runCareerChain(不經此工具)

綜合數據

請求 耗時 Tool Call 路徑
GET /chat 45ms Next.js page
POST /api/chat(無 tool) 461ms career simple
POST /api/chat(有 tool) 2.0s ✅ searchKnowledge career simple
POST /api/chat(complex) 2.7s 平行代理路徑
POST /api/chat(complex) 3.0s 平行代理路徑

風險與備援

風險一:模型不支援 tool calling

如果 Groq 更換或停用 llama-3.1-8b-instant,新的模型可能不支援 tools 參數。此時 LLM 會忽略 tools,直接回答,tool_calls 永遠為空。系統不會壞,但回到重構前的狀態——沒有強制注入但也沒有 Tool Use。

緩解:已在 llama-3.1-8b-instant 上實測確認支援(finish_reason = “tool_calls”,正確輸出 JSON arguments)。如果不支援,系統行為等同「沒帶 tools 參數」,LLM 直接用自身知識回答。

風險二:多一次 LLM round-trip 增加延遲

路徑 A(呼叫工具)需要兩次 Groq API 呼叫,總延遲約 1.5-2.5s,而重構前只需要一次(約 0.5-1s)。但重構前的 0.5-1s 已經包含了知識庫搜尋的延遲。

實際比較:

重構前 career simple:
  知識庫搜尋(~500ms)→ 一次 LLM(~500ms) = 總共 ~1.0s

重構後路徑 B(不查工具):
  一次 LLM(~500ms)= 總共 ~0.5s(快 2x,且 chunk 不佔 context window)

重構後路徑 A(查工具):
  第一次 LLM 決策(~200ms)→ 知識庫搜尋(~200ms)→ 第二次 LLM(~500ms)= 總共 ~0.9s

重構後路徑 A 跟重構前總耗時差不多(甚至略快,因為 Phase 1 決策比 embedding 搜尋快),而且多了 LLM 判斷「要不要查」的能力。路徑 B 則明顯更快。

緩解:第二次 LLM 呼叫的 max_tokens 可從 512 降至 256(因為工具已提供 chunk,LLM 只需包裝回答),或降低 temperature 減少隨機性。

風險三:模型同時輸出 content 與 tool_calls

某些模型(如 GPT-4 早期版本)可能在同一次回應中同時輸出 content 文字和 tool_calls。例如先串流「根據我的經驗…」幾個字,然後在同一個 stream 中又輸出 tool_call。這時使用者會先看到前半段文字,然後串流中斷去執行工具,等第二次 LLM 再重新輸出。

但這個行為取決於模型的解碼策略。LLM 在生成第一個 token 之前就已經決定好要輸出文字還是呼叫工具。contenttool_calls 是互斥的:要嘛 decoder 專注生成文字,要嘛專注生成 function arguments。

實測llama-3.1-8b-instant 在工具呼叫時 content 固定為 null,從頭到尾不產生任何文字。

緩解:如果未來換模型有此問題,可加入 buffer 前 N 個 chunk 的保護機制。

風險四:搜尋結果為空

知識庫 embedding 搜尋可能回傳零結果(例如問題太冷門、query 跟知識庫內容完全不匹配)。此時 searchKnowledge 回傳「(知識庫無相關結果)」,LLM 收到後用自身知識回答。

緩解:system prompt 已明確指示「如果知識庫沒有相關資料,根據你的專業知識回答,不要憑空編造」。這是最壞情況的 graceful degradation。

風險五:LLM 胡亂呼叫工具

LLM 可能在不需要知識庫時也呼叫 searchKnowledge,浪費延遲與 token。

緩解

  1. tool_choice: "auto" 讓 LLM 自行判斷,而非強制使用工具(若是 tool_choice: "required" 則每題都查)
  2. system prompt 寫「當你需要特定資訊時,使用 searchKnowledge 工具」,強調「需要時」
  3. 未來可在 tool call 前加入 validation layer,過濾明顯不必要的查詢

總結

從強制注入到 Tool Use,這個過程不是在一次改造內完成的,而是在多次迭代中逐步推進:先確定 LLM 支援 tool calling,再把強制注入改為工具定義,接著調整 streaming 流程,最後補上邊界情況的處理。每一步都是增量改動,每次上線都是安全的。

但貫穿所有迭代的核心思路不變:把決定權從程式碼還給 LLM

重構前,程式碼像一個不放心的主管,不管員工需不需要,先把一堆資料塞到他桌上。更糟的是,這些資料還不一定相關。

重構後,LLM 像一個成熟的員工,知道自己什麼時候該查資料、什麼時候自己就能回答。該快的時候更快(461ms vs 1.0s),該 grounded 的時候有 grounded(精準 query + 知識庫 chunk),而且不浪費 token 和 context window。

對於任何正在實作 LLM 知識庫應用的開發者,建議:不要強制注入,讓 LLM 自己決定。 Tool Use 不只是技術架構的改變,更是對 LLM 能力的信任投票。


Agentic Design Pattern Agentic AI Tool Use Function Calling LLM CareerWise AI Architecture Design Pattern