我的第一个 LangChain Agent:从代码到流程图,聊透工具调用

0 阅读5分钟

大家好,今天我用自己写的一段代码,跟你聊聊 Agent 到底是怎么“思考”和“动手”的。

先说一下场景:我想让 AI 帮我读取一个文件(比如 src/test/story.txt),然后总结一下内容。就这么简单。

大模型本身是没办法执行读取这个动作的,这个时候需要开发“工具”交给大模型去调用,通过工具来执行读取的动作。

定义工具

我用 LangChain 定义了一个 readFileTool,里面写了读取逻辑(fs.readFile),告诉模型这个工具叫什么、有什么用、需要什么参数(文件路径)。

// 定义读取文件的工具
const readFileTool = tool(
    async ({ filePath }) => {
        const content = await fs.readFile(filePath, 'utf-8')
        return content
    },
    {
        name: 'read_file',
        description: '用此工具来读取文件内容。当用户要求读取文件、分析文件内容时,调用此工具。',
        schema: z.object({
            filePath: z.string().describe('要读取的文件路径'),
        })
    }
)

然后把工具绑定到模型上:

// 将工具绑定到模型
const tools = [readFileTool]
const modelWithTools = model.bindTools(tools)

构建消息列表

大模型是无状态的,需要放到 messages 数组(模拟 memory 的概念)里给它才知道之前做过什么。

// 定义消息列表
const messages = [
    new SystemMessage({
        content: `
        你是一个专业的文件读取助手。你可以读取文件内容、分析文件内容等。
        可用工具:
        - read_file: 读取文件内容。
        `,
    }),
    new HumanMessage({
        content: '请读取文件 src/test/story.txt 的内容,并输出50字的内容来总结文件内容。',
    }),
]

第一次进行模型调用

第一次调用模型,把模型的能力和边界以及我的需求告诉他。

let result = await modelWithTools.invoke(messages)

调用之后模型会返回工具相关信息

image-20260407182949205.png

while 循环处理工具调用

// 进行工具调用
while (result.tool_calls && result.tool_calls.length > 0) {
​
    console.log(`共有 ${result.tool_calls.length} 个工具`)
​
    // 处理每个工具调用
    const toolResults = await Promise.all(
        result.tool_calls.map(async (toolCall) => {
            const tool = tools.find(t => t.name === toolCall.name);
​
            if (!tool) {
                return `未找到工具:${toolCall.name}`
            }
​
            console.log(`调用工具:${toolCall.name}, 参数:${JSON.stringify(toolCall.args)}`)
​
            try {
                const result = await tool.invoke(toolCall.args)
                console.log(`工具调用结果: ${result}`)
                return result
            } catch (error) {
                console.error(`工具调用错误: ${error.message}`)
                return `工具调用错误: ${error.message}`
            }
        })
    )
​
    // 将工具调用结果添加到消息列表
    result.tool_calls.forEach((toolCall, index) => {
        messages.push(
            // 通过 tool_call_id 来关联工具调用和结果,确保模型知道哪个工具调用的结果是哪个。
            new ToolMessage({
                content: toolResults[index],
                tool_call_id: toolCall.id,
            })
        )
    })
​
    // 调用模型,处理工具调用结果
    result = await modelWithTools.invoke(messages)
​
    console.log(`=====工具中调用中调用模型: `, result)
}

最终返回结果

当工具调用完成之后可以通过 result.content 得到最终的结果。

我的需求:

帮我读取文件 src/test/story.txt 中的内容,然后总结一下。

大模型给我的答复:

这个故事讲述了一本名为《星夜的记忆》的书及其背后的故事。书中记录了女教师与抗日战士的爱情故事,他们在战乱中分离,女教师终身未嫁,直到去世前仍把这份思念藏在了书里。现在,这本书仍然在一家旧书店中等待着下一个有缘人来发现它的秘密。

流程图

image-20260407183535699.png

可能产生的问题

定义工具时的 schema 和 description 有什么区别?

  • description 告诉大模型工具在什么场景下应用。
  • schema 告诉大模型,工具需要传哪些参数、每个参数是什么类型、每个参数代表什么意思。

为什么要使用 Zod 来定义 schema

Zod 是一个用于类型校验和数据解析的库,它可以帮助我们定义工具调用时需要传入的参数的类型、验证规则和描述信息,并且 LangChain 原生支持。

如果不使用 Zod,需要手动验证参数

// 不使用 Zod - 需要手动验证
const readFileTool = {
    name: 'read_file',
    parameters: {
        type: 'object',
        properties: {
            filePath: { type: 'string', description: '...' }
        }
    },
    execute: async (input) => {
        // 需要手动验证 input.filePath 是否存在、是否为字符串
        if (!input.filePath || typeof input.filePath !== 'string') {
            throw new Error('Invalid input')
        }
    }
}
​
// 使用 Zod - 自动验证
const readFileTool = tool(
    {
        schema: z.object({
            filePath: z.string().describe('...'),
        })
    },
    async ({ filePath }) => {
        // filePath 已经被 Zod 验证过,直接使用
    }
)

消息类型有哪些?

SystemMessage、HumanMessage、AIMessage、ToolMessage。

  • SystemMessage:系统消息,用于设置模型的行为和上下文。比如设置 AI 是谁,有什么能力,以及回答和行为的规范。
  • HumanMessage:用户消息,用户输入的文本。
  • AIMessage:模型消息,模型生成的文本,也就是 AI 回复的消息
  • ToolMessage:工具消息,模型调用工具的返回结果。

定义消息列表时为什么要为什么要先定义一个 SystemMessage?不定义可以吗?或者直接通过 HumanMessage 来描述“你是一个专业的文件读取助手”可以吗?

可以不定义,但是从规范角度出发建议定义。

  • 定义之后可以告诉模型的身份,让模型按照该身份去理解后续请求;在复杂任务(如工具调用、多轮对话)中,系统消息能帮助模型更好地理解上下文。
  • 不定义时,模型可能拒绝调用工具。例如,如果用户说“给我看看那个文件”,模型可能回复“我没有权限读取文件”,而不是自动调用 read_file。系统消息中可以提前声明“你有读取文件的工具,当用户需要文件内容时请调用它”;
  • 可以通过 HumanMessage 来定义,但多轮对话中如果没有系统消息长期锚定,随着对话变长,模型可能偏离预期行为(比如开始闲聊或拒绝工具调用)。

工具调用时采用 while 循环,并且 while 循环的条件是 result.tool_calls && result.tool_calls.length > 0,这样不会进入死循环吗?比如说检测到了1个工具,那么下次循环还是1,所以我理解就会一直循环下去。

不会,每调用一次工具都会将工具调用结果添加到 messages 数组中,然后再次调用模型。模型会返回一个全新的 result 对象。这个新 result 的 tool_calls 属性取决于模型在看到“历史消息 + 工具执行结果”后,是否还需要继续调用工具。 如果模型认为还需要更多工具(例如先读一个文件,再读另一个文件),它会再次返回新的 tool_calls,length 又会 > 0; 只要模型觉得任务完成,就不会再产生工具调用,length 变为 0。所以循环次数取决于模型自身的决策,因为每次新的 result 是模型重新思考后的输出。

下期预告

下期我们实现 mini cursor:大模型自动对项目文件增删改查,执行命令。欢迎关注。

如果你也在学 Agent,欢迎在评论区留下你的疑问,我们下期见!