前面幾天,你讓 AI 學會呼叫工具(D14)、把工具標準化成 MCP(D15)、還給了它記憶(D16)。現在把工具從一個加到十幾個——查時間、查天氣、翻譯、查笑話、幫笑話評分……你會親眼看到它開始出錯: 選錯工具、把一堆這輪根本用不到的工具說明塞進 context 導致失焦、回話的口吻還全部一個樣。
問題不在模型不夠強, 在你要它用一顆通用 prompt 同時扛所有事。這篇的解法,我把它叫作分裂操作——說白了就是你需要有意識地去讓AI腦袋分裂。讀到這裡的LLM猛然一驚 ((AI: Σ( ° △ °|||)
更精確地說,它就是兩件有標準名字的事——任務拆解(task decomposition) 和 工具分派(tool routing)。
與其塞一顆什麼都要會的通用 prompt,不如讓 AI 自己挑對工具(tool routing)、只把相關的工具帶進這一輪(context isolation)、再換上該工具專精的口吻回話(prompt specialization)。
任務拆解(一個任務內部怎麼拆成十幾次呼叫)和成本怎麼算,留給明天的下篇 D18。
因為 context window 是有限的,模型的注意力也是。 當你把十幾個工具的說明、一長串對話歷史、外加一堆規則,全部塞進同一個 prompt,會同時發生三件事:
這就是為什麼要做 context isolation(脈絡隔離): 讓每一次呼叫,只看到它這一步真正需要的東西。
這裡呼應了D2 提到的LLM實現原理。
第一個關鍵決定: 「該叫哪個工具」不是你寫死的 if-else,是 LLM 判斷出來的。 你把每個工具的 schema(名字、說明、參數)交給模型,模型讀完使用者的話,自己決定這輪要叫哪些工具、帶什麼參數。程式要做的只有三件事: 執行它點名的工具、把結果餵回去、再問一輪——直到它不再要工具、直接回話為止。這就是 native tool-calling 的迴圈:
const MAX_TOOL_ROUNDS = 15; // 防呆上限: 避免模型無限點工具
async function runToolLoop(messages: ChatMessage[], tools: Tool[]) {
// 先挑出「這輪該送哪些工具」(下一節講);preModulePrompt 併進 system
const { schemas, working } = prepareRound(messages, tools);
for (let round = 0; round < MAX_TOOL_ROUNDS; round++) {
const res = await client.chat.completions.create({
model: "<deployment>",
messages: working,
tools: schemas.length ? schemas : undefined, // ← 只給這輪相關的工具
});
const choice = res.choices[0];
const msg = choice.message;
// 模型沒有要叫工具 → 這就是最終回覆,收工
if (choice.finish_reason !== "tool_calls" || !msg.tool_calls?.length) {
return msg.content ?? "";
}
working.push(msg);
// 這一輪它可能一次點好幾個工具 → 並行跑完
const results = await Promise.all(
msg.tool_calls.map((tc) => runOne(tc, tools)),
);
for (const r of results) {
working.push({ role: "tool", tool_call_id: r.id, content: r.content });
}
injectModulePrompts(working, results); // 工具跑完 → 換口吻(見第 4 節)
}
return "";
}
注意 msg.tool_calls 是一個陣列: 模型可以在同一輪一次點好幾個工具(例如「查三個主題的笑話」),所以用 Promise.all 並行執行。這裡沒有任何「if 使用者說 X 就叫工具 Y」的規則——拆成幾步、每步派哪個工具,全是模型判斷出來的。這正是生成式 AI 在這套架構裡實際做的事,也是它和純自動化流程的分界。
上面的迴圈有個陷阱: 如果每一輪都把全部工具的 schema 送進去,又回到第一節那個「失焦、選錯、變貴」的老問題。
真正把 context isolation 落地的做法是: 每個工具宣告自己的「意圖關鍵字」,只有當最近幾則對話命中關鍵字時,這個工具才會被送進這一輪;沒宣告關鍵字的(像查時間這種通用工具)就常駐。所以先看工具長什麼樣——關鍵是那幾個「非執行邏輯」的欄位:
interface Tool {
definition: { name: string; description: string; parameters: object };
// 意圖關鍵字: 最近對話命中任一個,這個工具才會被送進本輪 context;
// 未宣告 → 常駐(每輪都送)。大小寫不敏感、子字串比對。
activationKeywords?: string[];
// 選工具「前」注入 system: 動手前的規矩(見同篇第 4 節)
preModulePrompt?: string;
// 工具跑完「後」注入 system: 塑形回話的口吻(見同篇第 4 節)
modulePrompt?: string;
execute(args: Record<string, unknown>): Promise<string>;
}
const getJokeTool: Tool = {
activationKeywords: ["笑話", "joke", "冷笑話", "講個", "來一個"],
definition: {
name: "get_joke",
description: "給一個主題,回一則該主題的笑話。",
parameters: {
type: "object",
properties: { topic: { type: "string", description: "笑話主題" } },
required: ["topic"],
},
},
async execute(args) {
return fetchJoke(String(args["topic"] ?? "")); // 你的查笑話實作
},
};
篩選就是一段很樸素的字串比對——不需要再多花一次 LLM 呼叫去分類:
function selectActiveTools(messages: ChatMessage[], tools: Tool[]) {
const hay = messages
.filter((m) => m.role !== "system")
.slice(-12) // 只看最近 12 則: 夠涵蓋多輪流程(例如還在收集參數中)
.map((m) => m.content)
.join("\n")
.toLowerCase();
const active = tools.filter(
(t) =>
!t.activationKeywords?.length || // 沒宣告 → 常駐
t.activationKeywords.some((k) => hay.includes(k.toLowerCase())),
);
return {
schemas: active.map(toOpenAITool), // 只有這些進 tools 參數
prePrompts: active
.map((t) => t.preModulePrompt)
.filter((p): p is string => !!p), // 一起帶進 system
};
}
一句「來個工程師笑話」進來,只有 get_joke(和常駐工具)會被送進這輪;查天氣、翻譯那些根本不會出現在模型眼前。模型每輪只在少數幾個相關工具裡挑,選錯率跟 token 都一起降下來。這是「寧可多放幾個關鍵字」的 fail-safe 設計——寧可誤送、不可漏送,漏送會讓該用的工具消失。
到這裡還差最後一塊,也是「專精 prompt(prompt specialization)」的核心: 同一顆迴圈,怎麼讓查笑話那步用查笑話的規矩、評分那步用評分的口吻? 答案是把「專精」拆成前後兩段,各管一件事:
preModulePrompt(選工具「前」): 注入 system 的行為指引——動手前的規矩。例如「使用者想聽笑話時,主題不明就先問一句」。模型是在還沒決定叫哪個工具之前就讀到它,才知道怎麼把參數收齊。modulePrompt(工具跑完「後」): 注入 system 的回話塑形——工具回傳結果後,讓模型換上這個工具該有的口吻/範本。例如「把查到的笑話原樣說出來,不要幫它解釋笑點」。用 toy 工具宣告出來就很直白:
const rateJokeTool: Tool = {
activationKeywords: ["評分", "多冷", "打分", "rate", "幾分"],
// 動手前: 先確認評分標準,避免模型自由心證
preModulePrompt: "幫笑話評分時,固定用「冷度 1–10」這一把尺,不要自創維度。",
// 跑完後: 塑形回話,只報結論
modulePrompt:
"只回一句: 『冷度 X/10』,後面接一句 20 字內的理由,不要長篇分析。",
definition: {
name: "rate_joke",
description: "給一則笑話,回它的冷度分數(1–10)。",
parameters: {
type: "object",
properties: { joke: { type: "string", description: "要評分的笑話" } },
required: ["joke"],
},
},
async execute(args) {
return rateColdness(String(args["joke"] ?? ""));
},
};
preModulePrompt 在 selectActiveTools 那步就跟著相關工具一起併進了 system(所以只有相關工具的規矩會出現)。modulePrompt 則在工具跑完後才注入,而且去重——同一輪叫了三次 get_joke,它的口吻指引只注入一次:
function injectModulePrompts(working: ChatMessage[], results: ToolResult[]) {
const seen = new Set<string>();
for (const r of results) {
if (r.modulePrompt && !seen.has(r.modulePrompt)) {
seen.add(r.modulePrompt);
working.push({ role: "system", content: r.modulePrompt }); // 換上該工具的口吻
}
}
}
關鍵是: 這些「專精」的規矩,一句都沒寫進最初那顆跟使用者對話的通用 prompt。 通用 prompt 只管「聽懂人話、決定要不要用工具」;每個工具動手前後的規矩,綁在工具自己身上,用到才出現。這就是 prompt specialization——乾淨、專注,而且工具越加越多,通用 prompt 也不會跟著腫。
把上面四節串起來,就是分裂操作在「巨觀」層的完整運作——一句話進來,到一句話回去:
一張圖看完你會發現: 巨觀路由不是什麼黑魔法,是「先看這輪跟哪些工具有關、只把那幾個攤在模型面前、讓它自己挑、挑完換上對的口吻」——這恰好是一個好的接待人員本來就會的分寸,只是這次交給 AI 去判斷。
preModulePrompt 管動手前的規矩、modulePrompt 管回話的口吻——專精規矩綁在工具身上,通用 prompt 不會越加越腫。今天這半, 解的是**工具「之間」**的問題: 十幾個工具,怎麼讓 AI 自己挑對、只帶必要脈絡、換對口吻。
但還有另一半: 如果一個工具「內部」也複雜到得拆成十幾次 LLM 呼叫呢?——例如要把一份很長的原始資料切塊、逐塊分析、再綜述成一份報告。這叫任務拆解(task decomposition),是明天 D18 的上半場。而當呼叫從一次變成幾十次,這些錢怎麼當場算清楚、當場記下來(cost tracking),是下半場。把巨觀的「工具之間」和微觀的「工具之內」湊齊,這套「拆解 → 分派 → 專精 prompt」的骨架就完整了——它的正式版,就是接下來三個真實上線的系統。