第四章 03:4.3 Tools —— 让 Agent 会用工具

第四章 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)

相关推荐
Hpc_Fun1 小时前
GPU 工作站品牌怎么比?五把可核验尺子、加权评分脚本与场景重排
java·信创·gpu工作站·品牌对比·加权评分
光电的一只菜鸡1 小时前
高通中的深度学习模型部署概述
人工智能·深度学习
Rain的Java大神之路1 小时前
百万并发直播弹幕系统设计全解:看懂了扇出,你就看懂了高并发的八成
java·后端·面试
MIXrer1 小时前
告别护具泥地与天气掣肘:超元力灰烬行动重构 VR 真人 CS 竞技体验
人工智能·vr·娱乐
清华kenny1 小时前
解读晓得养AI辨证API对接现有HIS系统的可行性
大数据·人工智能·安徽晓得养
尹玉玺1 小时前
事实判断与价值判断的融合辨析:厘清道法自然与该当本然的边界
人工智能
9i编程1 小时前
用 Trae 吃透掘金文章:从拉取 Demo 到跑起 Three.js 太阳能电场,AI 问出来的学习路径
人工智能·openai·ai编程
\光辉岁月/1 小时前
2.异常类
java·开发语言·异常类