---
# System prepended metadata

title: 打造一個 AI Discord 聊天機器人？ - 工具篇
tags: [ChatBot, AI, LLM, Discord]

---

# 打造一個 AI Discord 聊天機器人？ - 工具篇

[TOC]

:::success
**[English Version](https://hackmd.io/@stanley2058/r1FbvLK3lg)**
{%preview https://hackmd.io/@stanley2058/r1FbvLK3lg %}

這是這個系列的第二篇文章，還沒看過第一篇的可以下面。
{%preview https://hackmd.io/@stanley2058/Hkzbhiv2ee %}
:::

## 0. 什麼是工具？

鐵錘、螺絲起子..哦不是那種工具是吧？

工具，就是讓 LLM 可以跟文字以外的東西進行互動的關鍵。舉例來說，模型本身沒有取得連結內容的功能，那麼在 ChatGPT 的網站上它是怎麽存取你給他的連結呢？實際上，模型在背後回去呼叫一個工具取得這個連結的內文，工具回應的內文會在被加回模型的資訊內。這樣一來一往，模型就能夠根據正確的內容產生回應了！


## 1. 那麼要怎麽造工具呢？

AI SDK 提供了很方便的且穩定的工具界面：

```typescript
import { tool } from 'ai';
import { z } from 'zod';

export const weatherTool = tool({
  description: 'Get the weather in a location',
  inputSchema: z.object({
    location: z.string().describe('The location to get the weather for'),
  }),
  // location below is inferred to be a string:
  execute: async ({ location }) => ({
    location,
    temperature: 72 + Math.floor(Math.random() * 21) - 10,
  }),
});
```

官方範例提供了最基本構成工具的要素：
- `description`：工具主要描述。
- `inputSchema`：用 `zod` 定義的輸入格式，是模型要提供給工具的參數。如果模型給了不正確的參數，或是錯誤的格式，則會回傳輸入驗證錯誤的訊息回去給模型。
    - 每一個欄位都可以在最後面加上 `.describe("說明文字")` 來告訴模型每個欄位的使用細節、情境和限制。
- `execute`：模型呼叫工具時實際上會執行的程式。回傳值會直接當成結果送回去給模型。


## 2. MCP 是什麼？也能用它嗎？

MCP 是 Model Context Protocol 的縮寫，是 Anthropic 提出的一個工具標準格式。支援三種傳輸格式：`stdio`、`http`、`sse`。

MCP 在 AI SDK 只有實驗性支援，API 界面可能會改動。不過既然他支援，我們就先當做可以用把 MCP 加進來吧！

```typescript
import { experimental_createMCPClient as createMCPClient, type Tool } from "ai";
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";

// local mcp
const fetchClient = await createMCPClient({
  transport: new StdioClientTransport({
    command: "uvx",
    args: ["mcp-server-fetch"],
    // env: { YOUR_ENV_VAR: "hello!" },
  }),
});

// remote mcp (http)
const context7Client = await createMCPClient({
  transport: new StreamableHTTPClientTransport(
    new URL("https://mcp.context7.com/mcp"),
    {
      requestInit: {
        headers: {
          "Authorization": "Bearer <CONTEXT7_API_KEY>",
        },
      },
    },
  ),
});

// remote mcp (sse)
const client = await createMCPClient({
  transport: {
    type: "sse",
    url: "<url using sse>",
    headers: {},
  },
});

// 轉成 ai-sdk 的工具格式
const tools = await Promise.all(
  [fetchClient, context7Client, client].map((t) => t.tools()),
);

```

## 3. 怎麽用工具？

有了幾個工具後，要讓模型可以使用工具才有用。不然就會變成這樣：
![image](https://hackmd.io/_uploads/SkRliSFhll.png)

### 基本呼叫方式

AI SDK 把這件事情做的很簡單，讓我們稍微借用一下之前寫過的程式：

```typescript
import { stepCountIs } from "ai";

// 前面的程式

const { textStream, response } = streamText({
  model: provider('gpt-5'),
  messages,

  // 加上這個把剛剛的 `tools` 傳進去就好了！
  tools, 

  // 最多執行幾輪，如果有工具呼叫會至少多一輪來回。
  // 因為這個數值預設是 1，如果不給大於 1 的話會卡在 `finishReason` 為 `tool_call`。
  stopWhen: stepCountIs(10),
});
```

不過這樣有個小問題，這個方法只適用原生就支援工具呼叫的模型。像是 `gpt-5-chat` 就不支援這個呼叫方式，如果傳了 `tools` 參數進去嘗試請求，OpenAI 的 API 會回應 400 錯誤告訴你這個模型不支援工具呼叫。

### 支援任何模型！

那麼要怎麽辦呢？其實，我們可以自己定義一個工具呼叫格式，在 system prompt 內告訴模型要怎麽呼叫工具，在串接 `textStream` 的時候去偵測是否有符合這個格式。符合的話就執行工具呼叫，並且把這一個呼叫格式從輸出中切掉。執行完工具後把結果拼回去 `messages` 中在次執行 `streamText` 直到結束 (`finishReason` = `stop`) 或超過最高輪迴上限。

我們先來假設一個工具呼叫的格式：`<tool-call tool="{name}">{payload}</tool-call>`

兩個簡單 function 的判斷目前的字串有沒有可能是個工具呼叫：
```typescript
function maybeToolCallStart(text: string) {
  const start = "<tool-call";
  for (let i = 0; i < Math.min(text.length, start.length); i++) {
    if (text[i] !== start[i]) return false;
  }
  return true;
}
function maybeToolCallEnd(text: string) {
  const end = "</tool-call>";
  for (let i = 0; i < Math.min(text.length, end.length); i++) {
    if (text[text.length - i - 1] !== end[end.length - i - 1]) return false;
  }
  return true;
}
```

原本 AI SDK 會自動幫我們插入工具的描述，但是我們現在要手動來做這件事情，所以要先把工具描述給做出來：
```typescript
import { asSchema, type ModelMessage } from "ai";

const toolDesc = Object.entries((tools = tools || {})).map(([name, tool]) => {
  return {
    name,
    description: tool.description,
    jsonSchema: asSchema(tool.inputSchema).jsonSchema,
  };
});

const toolSystemPrompt: ModelMessage = {
  role: "system",
  content:
    "Important rule to call tools:\n" +
    '- If you want to call a tool, you MUST ONLY output the tool call syntax: <tool-call tool="{name}">{payload}</tool-call>\n' +
    "- Examples:\n" +
    '  - <tool-call tool="fetch">{"url":"https://example.com","max_length":10000,"raw":false}</tool-call>\n' +
    '  - <tool-call tool="eval">{"code":"print(\'Hello World\')"}</tool-call>\n' +
    "\nAvailable tools:\n" +
    JSON.stringify(toolDesc, null, 2),
};
```

再來的程式會有點複雜，我們要重新包裝 `streamText` 並提供類似的界面。流程是這樣的：
1. 呼叫 `streamText`
2. 監控串流累積內容，如果是工具呼叫先存著，不是工具呼叫就把內容丟出去。
3. 串流結束後如果有工具呼叫則執行，沒有的話就可以結束了。
4. 工具呼叫完把結果加回去 `messages` 中，再從 1 開始。

:::spoiler 上面的程式
```typescript
export function streamTextWithCompatibleTools({
  tools,
  messages,
  ...rest
}: StreamTextParams) {
  messages = [...(messages || [])];

  const toolDesc = Object.entries((tools = tools || {})).map(([name, tool]) => {
    return {
      name,
      description: tool.description,
      jsonSchema: asSchema(tool.inputSchema).jsonSchema,
    };
  });
  const toolsSystemPrompt: ModelMessage = {
    role: "system",
    content:
      "Important rule to call tools:\n" +
      '- If you want to call a tool, you MUST ONLY output the tool call syntax: <tool-call tool="{name}">{payload}</tool-call>\n' +
      "- Examples:\n" +
      '  - <tool-call tool="fetch">{"url":"https://example.com","max_length":10000,"raw":false}</tool-call>\n' +
      '  - <tool-call tool="eval">{"code":"print(\'Hello World\')"}</tool-call>\n' +
      "\nAvailable tools:\n" +
      JSON.stringify(toolDesc, null, 2),
  };

  let callSequence = 0;
  const generateCallId = () => `${toolCallIdPrefix}-${++callSequence}`;
```
:::

```typescript
  const { promise: finishReason, resolve: resolveFinishReason } =
    Promise.withResolvers<FinishReason>();

  const finalResponsesAccu: ResponseMessage[] = [];
  const { promise: finalResponses, resolve: resolveFinalResponses } =
    Promise.withResolvers<{ messages: ResponseMessage[] }>();

  const TOOL_CALL_SINGLE = /<tool-call\s+tool="([^"]+)">([\s\S]*?)<\/tool-call>/;

  // 一個 async generator，等於原本的 `textStream`
  const textStreamOut = async function* () {
    while (true) {
      const { textStream, finishReason, response } = streamText({
        ...rest,
        messages: [toolsSystemPrompt, ...messages],
        prompt: undefined, // 這個是為了確保 type 是對的
        tools: undefined, // 確保沒有 `tools` 被傳進去
      });

      let buffer = "";
      let toolMatch: RegExpExecArray | null = null;
      let inToolCall = false;
      let carryOver = "";
      for await (const chunk of textStream) {
        if (inToolCall) {
          // 如果可能是工具呼叫就累積起來
          buffer += chunk;
        } else if (maybeToolCallStart(chunk) && !toolMatch) {
          // 如果可能是工具呼叫就開始累積
          inToolCall = true;
          buffer = chunk;
        } else {
          // 不是工具呼叫，丟出去然後繼續
          yield chunk;
          continue;
        }

        // 如果是合法工具呼叫就先存著
        if (inToolCall && maybeToolCallEnd(buffer)) {
          const match = buffer.match(TOOL_CALL_SINGLE);
          if (match) {
            const full = match[0];
            const idx = buffer.indexOf(full);
            const endIdx = idx + full.length;
            carryOver = buffer.slice(endIdx);

            toolMatch = [
              full,
              match[1],
              match[2],
            ] as unknown as RegExpExecArray;
          } else {
            yield buffer;
          }
          buffer = "";
          inToolCall = false;
        }
      }

      // 串流結束後如果 buffer 內有東西，大概是錯誤的工具呼叫語法，當成一般內容丟出去
      if (!toolMatch && buffer) {
        if (inToolCall) yield buffer;
        buffer = "";
        inToolCall = false;
      }

      const [, toolName, payload] = toolMatch ?? [];
      const tool = toolName && tools?.[toolName];
      // 沒有工具呼叫，結束串流
      if (!toolName || !tool || !tool.execute) {
        resolveFinishReason(await finishReason);

        if (carryOver) {
          yield carryOver;
          carryOver = "";
        }
        resolveFinalResponses({ messages: finalResponsesAccu });
        break;
      }

      console.log(`Calling tool in compatible mode: ${toolName}`);

      // 把這輪內容先放進去 messages 裡面
      const callId = generateCallId();
      const { messages: respMessages } = await response;
      messages.push(...respMessages);
      finalResponsesAccu.push(...respMessages);

      try {
        // 執行工具呼叫
        const toolResult: unknown = await tool.execute(tryParseJson(payload), {
          toolCallId: callId,
          messages: respMessages,
        });

        // 呼叫成功，當成系統訊息放進去 `messages` 內。
        // 正常的工具呼叫 `role` 會是 `tool`，不過某些 API 提供商會去對 `toolCallId` 之前
        // 是否存在，所以確保不會壞掉的方式就是當成系統訊息。
        messages.push({
          role: "system",
          content: JSON.stringify([
            {
              type: "tool-result",
              toolCallId: callId,
              toolName,
              output: toToolResultOutput(toolResult),
            },
          ]),
        });
      } catch (err) {
        // 呼叫失敗，告訴模型為什麼失敗
        messages.push({
          role: "system",
          content: JSON.stringify([
            {
              type: "tool-result",
              toolCallId: callId,
              toolName,
              output: {
                type: "error-text",
                value: `Tool execution failed: ${String(err)}`,
              },
            },
          ]),
        });
      }

      if (carryOver) {
        yield carryOver;
        carryOver = "";
      }
    }
  };

  return {
    textStream: textStreamOut(),
    finishReason,
    response: finalResponses,
  };
}
```

:::spoiler 後面的程式
```typescript
function toToolResultOutput(output: unknown): ToolResultPart["output"] {
  if (typeof output === "string") return { type: "text", value: output };
  // treat undefined/null as empty text
  if (output === undefined || output === null)
    return { type: "text", value: "" };
  try {
    JSON.stringify(output);
    return { type: "json", value: output as JSONValue };
  } catch {
    return { type: "error-text", value: "Non-serializable tool output" };
  }
}

function tryParseJson(raw: string | undefined): unknown {
  if (!raw) return undefined;
  const trimmed = raw.trim();
  if (!trimmed) return "";
  try {
    return JSON.parse(trimmed);
  } catch (error) {
    return trimmed;
  }
}
```
:::

## 4. 結束了？

對，這次是真的寫完了 :tada: :tada: :tada:

如果你覺得要寫這麼多太麻煩了，可以直接用我寫好的 [js-llmcord](https://github.com/stanley2058/js-llmcord) (~~無情推廣~~)。原本是從 [llmcord](https://github.com/jakobdylanc/llmcord) 改寫過來然後硬塞了工具進去，後來為了加 RAG 跟支援 `gpt-5-chat` 越改越多就變成幾乎重寫了 :sweat_smile:。

(曬一下可愛的機器人 (?))
![image](https://hackmd.io/_uploads/H1kjSUY2xl.png)

