导读
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.ts 的 inputSchema 字段)。我们的 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