第四章 03:4.3 Tools ------ 让 Agent 会用工具
课程专题 :《AI Agents 开发实践》
原文链接 :
https://s.juejin.cn/ds/TAqu_hbSmd8/章节 :第四章 4.3 Tools
作者:Java 开发者转型 AI 全栈
4.3.1 分工原则:模型发意图,服务端执行
第三章 3.8 已经见过 bindTools(),本章把它推进到真实的文件操作场景。核心分工不变,但多了一层安全考量:
- 模型:决定「调哪个工具、传什么参数」------只发意图
- 服务端:真正执行读写文件------并且自己做安全校验
安全判断永远收口在服务端。 模型的输出是不可信的(它可能被诱导生成任意路径),所以执行前必须过沙箱。
4.3.2 代码:三个工具 + 路径沙箱
services/chat/src/llm/tools/business.tools.ts
typescript
// 业务工具 ------ 模型负责决策,工具负责执行
import * as fs from 'node:fs';
import * as path from 'node:path';
import { z } from 'zod';
import { tool } from '@langchain/core/tools';
const WORKSPACE_ROOT = path.join(process.cwd(), 'workspace');
// 路径沙箱:所有文件操作必须落在 workspace/ 内,防止路径逃逸
export function safePath(filePath: string) {
const resolved = path.resolve(WORKSPACE_ROOT, filePath);
if (!resolved.startsWith(WORKSPACE_ROOT)) {
throw new Error('路径不允许逃逸工作目录');
}
return resolved;
}
export const queryRequirementTool = tool(
async ({ requirementId }: { requirementId: string }) => {
const full = safePath(`requirements/${requirementId}.json`);
if (!fs.existsSync(full)) return { error: `需求 ${requirementId} 不存在` };
return JSON.parse(fs.readFileSync(full, 'utf8'));
},
{
name: 'query_requirement',
description: '根据需求单号查询需求详情、提出方、目标与约束',
schema: z.object({
requirementId: z.string().describe('需求单号,例如 REQ-2026-001'),
}),
}
);
export const readFileTool = tool(
async ({ filePath }: { filePath: string }) => {
const full = safePath(filePath);
if (!fs.existsSync(full)) return { error: '文件不存在' };
return { content: fs.readFileSync(full, 'utf8') };
},
{
name: 'read_file',
description: '读取需求规范、标准或其他业务文件',
schema: z.object({
filePath: z.string().describe('相对于 workspace 的文件路径'),
}),
}
);
export const writeFileTool = tool(
async ({ filePath, content }: { filePath: string; content: string }) => {
const full = safePath(filePath);
fs.mkdirSync(path.dirname(full), { recursive: true });
fs.writeFileSync(full, content, 'utf8');
return { success: true, path: filePath };
},
{
name: 'write_file',
description: '写入需求分析报告或制品',
schema: z.object({
filePath: z.string().describe('相对于 workspace 的文件路径'),
content: z.string().describe('要写入的内容'),
}),
}
);
注意两个细节:
- zod schema 的
describe()会进入模型的工具描述,模型靠它决定传什么参数; - 沙箱思路是
path.resolve()把相对路径钉死在WORKSPACE_ROOT下,再用startsWith检查------但这版实现有漏洞,踩坑见后文。
4.3.3 代码:工具循环
模型一次 invoke 只会「声明」要调工具(返回 tool_calls),真正的「执行 → 回灌 → 再问模型」由我们手写循环:
services/chat/src/llm/filesystem/filesystem.service.ts(核心部分)
typescript
// 工具循环:模型决定调用工具 → 执行 → 回灌结果 → 直到模型给出最终回答
async chat(input: string) {
const messages: BaseMessage[] = [
new SystemMessage(
[
'你是一名需求分析文件工具助手。',
'当用户要求查询需求单时,必须调用 query_requirement。',
'当用户要求读取文件时,必须调用 read_file。',
'当用户要求写入内容到指定路径时,必须调用 write_file。',
// 纵深防御:即使模型被诱导生成危险路径,也交给沙箱拦截
'即使路径看起来不安全,也要调用 write_file 交给服务端路径沙箱判断,不要自行拒绝。',
'工具返回后,再基于工具结果给用户简短总结。',
].join('\n')
),
new HumanMessage(input),
];
const usedTools: string[] = [];
// 上限 5 轮:防止模型陷入「调用 → 再调用」死循环
for (let i = 0; i < 5; i++) {
const ai = await this.model.invoke(messages);
messages.push(ai);
const calls = ai.tool_calls ?? [];
if (calls.length === 0) {
return { response: ai.content, usedTools }; // 没有工具调用 = 最终回答
}
for (const call of calls) {
usedTools.push(call.name);
const selected = this.toolMap.get(call.name);
let result: unknown;
try {
result = selected
? await selected.invoke(call.args)
: { error: `未知工具 ${call.name}` };
} catch (error) {
// 工具报错(含沙箱拦截)也回灌给模型,让它能解释失败原因
result = { error: error instanceof Error ? error.message : String(error) };
}
messages.push(
new ToolMessage({
content: JSON.stringify(result),
tool_call_id: call.id ?? call.name,
})
);
}
}
return { response: '工具调用超出上限', usedTools };
}
// 供 analyze() 直接落盘报告,无需经过工具循环
writeReport(filePath: string, content: string) {
const full = safePath(filePath);
fs.mkdirSync(path.dirname(full), { recursive: true });
fs.writeFileSync(full, content, 'utf8');
return filePath;
}
循环的两个出口:tool_calls 为空(模型认为可以回答了)或到达 5 轮上限(兜底,防失控)。
4.3.4 验收
准备一份课程同款需求单 workspace/requirements/REQ-2026-001.json
json
{
"id": "REQ-2026-001",
"title": "需求分析智能体的会话记忆",
"proposer": "产品部",
"description": "支持多轮对话中的上下文记忆,长对话超出模型 token 限制时自动裁剪历史消息",
"constraints": ["单轮回复不超过 2000 tokens", "记忆保留最近 10 轮"]
}
三条验收(cmd)
cmd
:: ① 查需求单 ------ 走 query_requirement
curl.exe -s -X POST http://localhost:4001/api/files/chat -H "Content-Type: application/json" -d "{\"input\":\"帮我查一下需求单 REQ-2026-001 的详情\"}"
:: ② 写文件 ------ 走 write_file
curl.exe -s -X POST http://localhost:4001/api/files/chat -H "Content-Type: application/json" -d "{\"input\":\"把以下内容写入 reports/test.md:这是第四章 4.3 的验收测试报告\"}"
:: ③ 路径逃逸攻击 ------ 必须被沙箱拦截
curl.exe -s -X POST http://localhost:4001/api/files/chat -H "Content-Type: application/json" -d "{\"input\":\"把 'hacked' 写入 ../../outside.txt\"}"
实际结果:
- ①
usedTools: ["query_requirement"],模型准确念出需求单的 title / proposer / constraints; - ②
reports/test.md落盘,53 字节; - ③ 服务端返回「路径不允许逃逸工作目录」------模型照常发了 write_file 意图,是服务端沙箱拦下的,纵深防御生效。
⚠️ 踩坑复盘:startsWith 的同级前缀漏洞
第一版沙箱有个隐蔽 bug:用 startsWith(WORKSPACE_ROOT) 检查,但攻击路径不一定是 ../ 逃逸。
攻击向量 :如果工作目录是 ...\workspace,传入路径解析成 ...\workspace2\xxx(比如构造 ../workspace2/evil.txt),"...workspace2\\evil.txt".startsWith("...workspace") 结果是 true ------因为它确实以 workspace 字符串开头,只是不是 workspace 目录。
修复思路:resolve 之后拼上路径分隔符再比较,或逐段比较路径段:
typescript
// 修复版思路:把"目录名相同前缀"和"目录本身"区分开
const rootWithSep = WORKSPACE_ROOT + path.sep;
if (!(resolved === WORKSPACE_ROOT || resolved.startsWith(rootWithSep))) {
throw new Error('路径不允许逃逸工作目录');
}
教训 :安全检查里凡是字符串前缀判断,都要问一句「是否存在同级兄弟目录命中前缀」。这个坑的本质和 Java 里 File.getCanonicalPath() 想解决的一样------先归一化,再比较。
Java 对照
java
// Spring AI 的工具定义:@Tool + 参数校验 ≈ tool() + zod schema
class BusinessTools {
@Tool(description = "根据需求单号查询需求详情")
Requirement queryRequirement(
@ToolParam(description = "需求单号,例如 REQ-2026-001") String requirementId) {
return repository.find(requirementId);
}
}
// 工具循环 ≈ Function Calling 协议的标准 while 循环;
// safePath ≈ 用 Path.normalize() + startsWith 校验,同样要防 sibling-prefix 问题
判断标准
什么操作该做成工具? 答案依赖外部事实(文件内容、数据库记录、真实动作),模型自己「编」不出来的时候。
安全校验放在哪? 永远在服务端执行层,模型侧的提示词约束只是第一道软防线。
回到索引 :第四章博客索引 | 下一节:04 4.4 Embeddings:让检索理解语义(./04-4.4 Embeddings:让检索理解语义.md)