Day 2: 给 AI 装上双手 —— 工具工程化

0 阅读1分钟

导读

Day 1 的 LLM 只会"说话"。但一个真正的 Agent 需要操作外部世界——读写文件、执行命令、查询数据库。

这一天,我们通过 Function Calling(函数调用)让 LLM 拥有操作能力。更重要的是,我们会学到 Claude Code 的核心工具设计模式:buildTool() 工厂——一个让工具注册变得优雅的设计。

学完这一天,你会理解:

  • Function Calling 的完整协议流程
  • 为什么 Claude Code 不用手写 JSON Schema
  • 工具结果大小限制的必要性
  • isReadOnly 标记的工程价值

2.1 核心概念

Function Calling 协议

JSON Schema vs Zod:两种定义工具的方式

方式

示例

优缺点

手写 JSON Schema

{ type: "object", properties: { filePath: { type: "string" } } }

✅ 直观 ❌ 冗长、无类型推导

Zod Schema

z.object({ filePath: z.string().describe("文件路径") })

✅ 简洁、有类型推导、可运行时校验 ❌ 需额外依赖

Claude Code 使用 Zod(见 Tool.tsinputSchema 字段)。我们的 MVP 先用 JSON Schema 教原理,Day 4 引入 Zod。

2.2 工具定义

传统方式:手写 JSON Schema

export const toolDefinitions: OpenAI.Chat.ChatCompletionTool[] = [
  {
    type: "function",
    function: {
      name: "readFile",
      description: "读取指定路径的本地文件内容",
      parameters: {
        type: "object",
        properties: {
          filePath: {
            type: "string",
            description: "要读取的文件路径",
          },
        },
        required: ["filePath"],
      },
    },
  },
  {
    type: "function",
    function: {
      name: "writeFile",
      description: "将内容写入指定路径的文件",
      parameters: {
        type: "object",
        properties: {
          filePath: {
            type: "string",
            description: "要写入的文件路径",
          },
          content: {
            type: "string",
            description: "要写入的文件内容",
          },
        },
        required: ["filePath", "content"],
      },
    },
  },
];

工具处理器

const MAX_RESULT_CHARS = 10000; // 工具结果大小限制

export const toolHandlers: Record<
  string,
  (args: Record<string, any>) => string
> = {
  readFile: (args) => {
    try {
      const content = fs.readFileSync(path.resolve(args.filePath), "utf-8");
      // ⚠️ 限制结果大小,防止超大文件撑爆上下文
      if (content.length > MAX_RESULT_CHARS) {
        return `📄 文件内容(前 ${MAX_RESULT_CHARS} 字符):\n\n${content.slice(0, MAX_RESULT_CHARS)}\n\n... [已截断,完整文件共 ${content.length} 字符]`;
      }
      return `📄 文件内容:\n\n${content}`;
    } catch (e: any) {
      return `❌ 读取失败: ${e.message}`;
    }
  },

  writeFile: (args) => {
    try {
      fs.mkdirSync(path.dirname(path.resolve(args.filePath)), {
        recursive: true,
      });
      fs.writeFileSync(path.resolve(args.filePath), args.content, "utf-8");
      return `✅ 文件已写入: ${args.filePath} (${args.content.length} 字符)`;
    } catch (e: any) {
      return `❌ 写入失败: ${e.message}`;
    }
  },
};

💡 为什么要限制结果大小? Claude Code 的 Tool.ts 定义了 maxResultSizeChars 字段。一个 100MB 的日志文件如果原样塞进 messages,会直接耗尽上下文窗口。截断 + 提示是最简单有效的方案。

2.3 类型安全:OpenAI SDK v6 的坑

OpenAI SDK v6 对 tool_calls 类型做了破坏性变更

// SDK v6 的 tool_call 是联合类型
type ChatCompletionMessageToolCall =
  | ChatCompletionMessageFunctionToolCall // ← 有 .function 属性
  | ChatCompletionMessageCustomToolCall; // ← 没有 .function 属性!

直接访问 .function 会报 TypeScript 错误。我们用一个类型收窄函数解决:

function extractToolCall(
  toolCall: OpenAI.Chat.Completions.ChatCompletionMessageToolCall,
): { id: string; name: string; args: Record<string, any> } | null {
  if ("function" in toolCall && toolCall.function) {
    return {
      id: toolCall.id,
      name: toolCall.function.name,
      args: JSON.parse(toolCall.function.arguments || "{}"),
    };
  }
  return null;
}

2.4 完整调用流程

const response = await client.chat.completions.create({
  model: MODEL,
  messages,
  tools: toolDefinitions,
  stream: false, // ← 工具调用模式用非流式
});

const choice = response.choices[0]!;
const message = choice.message;

if (choice.finish_reason === "tool_calls" || message.tool_calls?.length) {
  // LLM 想调用工具
  messages.push({
    role: "assistant",
    content: message.content,
    tool_calls: message.tool_calls,
  });

  for (const toolCall of message.tool_calls || []) {
    const extracted = extractToolCall(toolCall);
    if (!extracted) continue;

    console.log(
      `🔧 调用工具: ${extracted.name}(${JSON.stringify(extracted.args)})`,
    );

    const handler = toolHandlers[extracted.name];
    const result = handler
      ? handler(extracted.args)
      : `❌ 未知工具: ${extracted.name}`;

    // 把结果推回 messages,让 LLM 知道执行结果
    messages.push({
      role: "tool",
      tool_call_id: toolCall.id, // ← 必须匹配原始调用 ID
      content: result,
    });
  }
} else {
  // LLM 直接回答
  console.log(`AI: ${message.content}`);
}

2.5 isReadOnly 标记(为 Day 3 铺路)

在 Claude Code 中,每个工具都有 isReadOnly 属性:

工具

isReadOnly

含义

readFile

true

只读,不修改任何东西

grep

true

只读

writeFile

false

写操作

bash

false

可能修改

为什么重要? Day 3 我们会用这个标记实现读操作并发执行——多个 readFile 可以 Promise.all 并行,而 writeFile 必须串行。

2.6 常见问题

Q: Function Calling 和 Prompt Engineering 让 LLM 输出 JSON 有什么区别?

参考回答

Function Calling 是 API 层面的协议——LLM 在训练时就经过了工具调用的微调,输出的 JSON 格式更稳定。纯 prompt 让 LLM 输出 JSON 容易出现格式错误(尤其是复杂嵌套时)。此外,Function Calling 的 finish_reason 能告诉你 LLM 是否真的想调用工具,而不是你自己去猜。

Q: tool_call_id 是什么?为什么必须回传?

参考回答

tool_call_id 是 API 用来关联"调用请求"和"执行结果"的唯一标识。LLM 可能一次请求多个工具调用,每个都有独立 ID。回传时必须匹配,否则 API 无法知道哪个结果对应哪个调用。

Q: 工具结果太大怎么办?

参考回答

Claude Code 的做法:每个工具定义 maxResultSizeChars,超出限制时截断并持久化到文件,只给 LLM 一个摘要 + 文件路径。简化版就是直接截断 + 告诉 LLM 结果被截断了。

2.7 本日回顾

✅ 学到了什么:
  - Function Calling 的完整 6 步协议
  - JSON Schema 定义工具参数
  - tool_call_id 的匹配机制
  - 工具结果大小限制的重要性
  - OpenAI SDK v6 的类型收窄技巧

⚠️ Day 2 的局限:
  - 只能调用一次工具就结束
  - 不能"先读文件→分析→再写文件"

🔗 Day 3 解决方案:
  - 把工具调用放进 while 循环 → ReAct Agent Loop

上一篇:← Day 1: 最小 IO 核心

下一篇:Day 3: 核心引擎 —— Agent 状态机 →