大家好,今天我用自己写的一段代码,跟你聊聊 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)
调用之后模型会返回工具相关信息
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 中的内容,然后总结一下。
大模型给我的答复:
这个故事讲述了一本名为《星夜的记忆》的书及其背后的故事。书中记录了女教师与抗日战士的爱情故事,他们在战乱中分离,女教师终身未嫁,直到去世前仍把这份思念藏在了书里。现在,这本书仍然在一家旧书店中等待着下一个有缘人来发现它的秘密。
流程图
可能产生的问题
定义工具时的 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,欢迎在评论区留下你的疑问,我们下期见!