CareerWise 從強制注入到 Tool Use:逐步重構 LLM 知識庫搜尋的真實案例
16 Jul 2026本文記錄 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() 將整本知識庫全部丟進去。
完整的流程如下:
- 使用者說「怎麼談薪水?」
- 程式碼直接拿「怎麼談薪水?」當 query 去
searchSimilar() - chunk 硬塞進 system prompt 的「知識庫:」區塊
- LLM 根本不知道有搜尋這回事,眼前就突然多了一段文字
- 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:LLM 看問題,決定「我需要查知識庫」,輸出 tool_call(或決定不查,直接回答)
- Phase 2:程式碼端執行工具(LLM 不在這個階段)
- Phase 3:把查到的結果餵給 LLM,讓它根據結果回答(第二次 API 呼叫)
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,content 為 null,使用者無感)+ 知識庫搜尋(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 的意圖後再決定要不要顯示,那麼:
- 非工具路徑的使用者會感受到「等了整段 LLM 回應時間才看到第一個字」
- Streaming 的即時感完全喪失
因此採用 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
getReplyStream():改為兩階段 streaming。Phase 1 帶 tools 參數;若finish_reason === "tool_calls",執行工具後進行 Phase 3 第二次 streaminggetReply():同樣邏輯的非串流版本(供 LINE Bot 使用)- 移除
getKnowledge()的強制注入邏輯
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": []}'
預期行為:
- 回應
Content-Type: text/event-stream(因非 complex,走 streaming) - 若 LLM 判斷需要知識庫,響應前有約 0.5-1s 停頓(Phase 1 + 2),之後顯示 grounded 內容
- 若 LLM 直接用自身知識回答,正常逐字串流
檢查 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_calls 且 tool_calls[0].function.name === "searchKnowledge"。
測試三:本地啟動後用瀏覽器測試
npm run dev
開啟 http://localhost:3000 → 登入 → 在 chat 頁面輸入「怎麼談薪水?」或「前端轉職要注意什麼?」。
預期行為:
- 送出後短暫停頓,然後看到 grounded 回應
- 回應內容與知識庫內容一致,非 LLM 憑空生成
- 輸入「你好」則正常打招呼,不觸發工具
測試四:邊界案例
| 測試輸入 | 預期行為 |
|---|---|
| 「你好」 | 不觸發 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 之前就已經決定好要輸出文字還是呼叫工具。content 和 tool_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。
緩解:
tool_choice: "auto"讓 LLM 自行判斷,而非強制使用工具(若是tool_choice: "required"則每題都查)- system prompt 寫「當你需要特定資訊時,使用 searchKnowledge 工具」,強調「需要時」
- 未來可在 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 能力的信任投票。