Agent 入门:从 LLM 与 Agent 的区别到 Function Calling
上一篇文章,我还在用 Prompt 完成 NLP 翻译,理解的是"怎样把大模型能力封装成一个可复用的接口"。这次继续往前走:当大模型不再只生成一段文本,而是能够判断任务、选择工具并推动任务完成时,它就开始从 LLM 走向 Agent。
现在常见的 AI 产品,为什么越来越像 Agent
一个很现实的变化:现在很多 AI 产品相比于22年、23年的GPT来说,本质上已经不只是一个聊天框,而是在向 Agent(智能体)演化。
Cursor、Claude Code、Codex、豆包、OpenClaw、Hermes、悟空、WorkBuddy、飞书 CLI,这些产品背后的核心都离不开 LLM,但它们和单纯的大模型又不完全一样。最直观的区别是:LLM 更像是在回答问题,Agent 则开始帮我干活。
Agent 不只能生成一段文字,还可以在权限允许的范围内:
- 读取文件;
- 搜索网络资料;
- 编写和修改代码;
- 操作浏览器;
- 调用企业级软件;
- 操作电脑并完成一连串任务。
FDE 这类岗位所做的事情,也可以从这里理解:通过开发面向具体业务的定制化 Agent,帮助企业把 AI 真正接入工作流,从而降本增效。课堂上提到"Agent 工程师正在刷新软件工程岗位的工资上限",我更愿意把它理解为一种能力迁移:未来值钱的不只是会调用模型,而是能把模型、工具和业务上下文组织成可靠系统的人。
这张图把 Agent 的组成总结得很清楚。一个 Agent 有多强,主要取决于三件事:
- 用了什么大脑,也就是 LLM:决定它的理解、推理和生成能力;
- 装了什么工具,也就是 Tool:决定它能执行哪些具体动作;
- 拿到了什么信息,也就是 Context:决定它知道多少背景,能否基于正确数据做判断。
可以先把它记成一个不严格但很好用的公式:
text
Agent = LLM + Tool + Context + 执行循环
Agent 和 LLM 的区别:会思考,不等于会行动
LLM 是 Agent 的"大脑"。例如豆包背后有字节的大模型,Claude 背后是 Anthropic 的模型。大模型擅长的事情主要有两类:
- 推理:理解问题、拆解任务、规划步骤;
- 生成:输出自然语言、代码或其他形式的内容。
但 LLM 本身并不能直接读取我电脑里的文件,也不能真的打开浏览器、查询实时股票价格或者修改项目代码。它可以告诉我"应该查一下股价",却不会仅凭文字生成能力就获得实时数据。
这就是 LLM 与 Agent 的关键分界:
| 对比项 | LLM | Agent |
|---|---|---|
| 核心职责 | 推理与生成 | 理解需求、规划步骤并完成任务 |
| 是否直接连接外部世界 | 通常不能 | 可以通过 Tool 连接 |
| 是否拥有业务上下文 | 依赖输入的消息 | 可以持续读取文件、数据与环境信息 |
| 输出 | 文本或结构化内容 | 文本、工具调用以及实际操作结果 |
所以,Agent 并不是和 LLM 完全不同的东西。更准确地说,LLM 是 Agent 的一个核心组成部分,而不是 Agent 的全部。
Tool:给大模型补上一双"手"
如果说 LLM 是大脑,那么 Tool 就是 Agent 接触外部世界的手。
没有 Tool,AI 只能在已有上下文里推理和生成;有了 Tool,它才可能读取真实数据、调用接口、操作软件并完成自动化任务。课堂上的问题是:
青岛啤酒的股价是多少?
模型可以先通过推理发现,自己缺少实时或业务数据,因此需要调用一个查询股票价格的函数。完整链路应该是:
text
用户提问
↓
LLM 判断需要外部数据
↓
LLM 生成 tool call(工具名 + 参数)
↓
程序执行 get_closing_price
↓
程序把工具结果放回 messages
↓
再次请求 completion
↓
LLM 根据结果生成最终回答
这里最重要的一点是:LLM 不会直接执行 JavaScript 函数。 它只会按照约定返回"我想调用哪个工具、需要什么参数"。真正执行函数的是我们自己的程序。程序执行完后,还要把结果交还给 LLM,再进行一次 completion。这个过程通常会放进一个循环,直到模型不再请求工具为止。
先搭好调用大模型的项目
项目叫 reason-demo,主要依赖 dotenv 和 openai:
json
{
"name": "reason-demo",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"keywords": [],
"author": "",
"license": "ISC",
"type": "commonjs",
"dependencies": {
"dotenv": "^17.4.2",
"openai": "^6.45.0"
}
}
pnpm-lock.yaml 使用 lockfile v9,其中 autoInstallPeers 为 true、excludeLinksFromLockfile 为 false,实际锁定了 dotenv@17.4.2 和 openai@6.45.0。OpenAI SDK 的 AWS 凭证、WebSocket、Zod 等 peer dependency 在当前项目里都是可选项。
项目把敏感配置放在 .env 中,包含三个变量:
dotenv
DASHSCOPE_API_KEY=...
DASHSCOPE_API_BASE_URL=...
DASHSCOPE_MODEL=...
这里只记录变量名,真实值继续留在本地环境变量里。
client.mjs:把模型配置隔离出来
client.mjs 负责创建并导出 LLM Client:
javascript
import 'dotenv/config';
import OpenAI from 'openai';
// 负责提供 LLM Client 对象
const client = new OpenAI({
apiKey: process.env.DASHSCOPE_API_KEY,
baseURL: process.env.DASHSCOPE_API_BASE_URL,
model: process.env.DASHSCOPE_MODEL
});
// export const a = 2; // 直接导出
// export const b = 3;
export default client; // 默认导出 一个文件只能有一个默认导出
这段代码顺便复习了 ES Module 的两种导出方式:命名导出可以有多个,默认导出在一个模块中只能有一个。虽然 package.json 里写的是 "type": "commonjs",但这些文件使用 .mjs 扩展名,因此 Node.js 仍然会把它们当作 ES Module 处理。
main.mjs:用 messages 保存多轮对话
在接入工具之前,我们先用一个普通对话理解 messages 和推理输出:
javascript
import client from './client.mjs';
const main = async () => {
const result = await client.chat.completions.create({
model: process.env.DASHSCOPE_MODEL,
reasoning_effort: "high",// 工作之前,先深度推理一下
messages: [
{
role: "system",
content: "你是一个足球领域的中文专家,请使用中文尽量帮我回答与足球相关的问题"
},
{
role: "user",
content: "C 罗是哪个国家的足球运动员"
},
{
role: "assistant",
content: "C 罗是葡萄牙的足球运动员"
},
{
role: "user",
content: "内马尔呢?"
}
]
});
// 由于 LLM 能力提升,交给 LLM 处理的任务与问题也会更加复杂
// 对于复杂问题的处理,思考过程就尤为重要
// 那么如果我们先让 LLM 将思考过程展示出来,我们先做第一层的校验,解决问题的概率就会更高
console.log(`思考过程:`);
console.log(result.choices[0].message.reasoning_content);
console.log(`\n最终回答:`);
console.log(result.choices[0].message.content);
}
main();
messages 是一个按时间排列的消息列表。system 设定模型身份与回答范围,user 保存用户输入,assistant 保存模型此前的回答。最后一句只有"内马尔呢?",但模型能够结合前面的消息,理解用户仍然在问"他是哪个国家的足球运动员"。这就是多轮对话上下文的作用。
这里还有两个与 Reasoning 有关的字段:
reasoning_effort: "high":要求模型在回答前投入更多推理;reasoning_content:模型返回的推理内容;content:最终展示给用户的回答。
最后直接打印了 reasoning_content,是为了观察和校验模型的规划过程。这里还可以用流式输出持续观察推理过程,从而更早了解模型如何规划,并在必要时介入或调整生成。在实际产品中,是否能取得、是否应该向用户展示完整推理内容,要以所用模型和接口的能力及规范为准;更稳定的做法通常是让模型输出简短、可验证的步骤或结论依据。
completions.mjs:把常用调用封装成函数
项目还准备了一个通用的补全函数:
javascript
import client from './client.mjs';
export async function getCompletions(prompt) {
const response = await client.chat.completions.create({
model: process.env.DASHSCOPE_MODEL,
messages: [
{
role: "user",
content: prompt
}
]
});
return response.choices[0].message.content;
}
export async function getImages(prompt) {
return await client.images.create({
});
}
getCompletions(prompt) 把"组装单轮消息、调用模型、取出 content"封装起来,业务代码只需要关心 Prompt。getImages(prompt) 目前只是一个生图接口的函数雏形:参数 prompt 还没有传入 client.images.create,请求体也为空。这是课堂代码当前的真实状态,还不能把它当成已经完成的生图函数。
Tool Schema:不是把函数名告诉模型就够了
要让 LLM 使用工具,第一步不是执行函数,而是声明有哪些工具可用 。OpenAI 兼容接口通过 tools 数组描述工具:
javascript
const tools = [
{
type: "function",// tool 就是函数(用来完成某个功能) tool 是有专门的格式的
function: {
// 函数名
name: "get_closing_price",
// 函数描述,详细具体
// 描述了函数的功能,以及参数的含义和返回值
// 是 LLM 理解 tool 功能的重要依据
// LLM 能不能准确使用 tool 的核心
description: "获取股票的收盘价(单位:元),参数为股票代码,返回值为收盘价",
parameters:{
type: "object",
properties: {
stock_name: {
type: "string",
// 准确的语义 NLP
description: "股票名称"
}
},
required: ["stock_name"] // 必须传递 stock_name 参数
}
}
}
]
这个声明可以拆成四层:
type: "function":告诉模型这是一个函数工具;name:工具的唯一名称;description:说明工具能做什么;parameters:用 JSON Schema 说明参数的类型、语义和必填项。
这里需要反复强调的是"准确"。模型能不能选对工具、传对参数,很大程度上取决于工具描述是否清晰。代码中的描述写的是"参数为股票代码",但实际字段叫 stock_name,描述又写着"股票名称",三处语义并不完全一致。这正好提醒我:Tool Schema 不是走手续,命名与描述必须统一,否则会直接影响调用准确率。
工具声明和工具实现是两回事
tools 只是给模型看的"说明书",真正执行的 JavaScript 函数还需要自己实现:
javascript
// 具体实现 tool 函数
function get_closing_price(stock_name) {
if(stock_name === "青岛啤酒"){
return '67.92';
}
if(stock_name === "贵州茅台"){
return '1488.20';
}
return '未找到该股票';
}
这个 Demo 没有调用真实股票接口,而是用条件判断模拟数据源:青岛啤酒返回 67.92,贵州茅台返回 1488.20,其他名称返回"未找到该股票"。因此这两个数字只是文章演示数据,不能当作实时股价。
模型负责决定"需要调用 get_closing_price,参数是青岛啤酒",程序负责真正运行函数。这个职责边界非常重要:模型做语义理解和规划,普通代码做确定性执行。
把 tools 交给模型,让它自己判断是否调用
我把发送消息的逻辑封装成了 sendMessage:
javascript
const sendMessage = async (messages) => {
return await client.chat.completions.create({
model: process.env.DASHSCOPE_MODEL,
reasoning_effort: "high",// 工作之前,先深度推理一下
messages: messages,
tools: tools,
tool_choice: "auto"
});
}
tools 把工具说明交给模型,tool_choice: "auto" 则允许模型根据问题自行判断:可以直接回答,也可以请求调用某个工具。
完整的课堂入口代码如下:
javascript
import client from './client.mjs';
// 声明有哪些 tools
// 告诉了 LLM ,LLM 知道在推理的时候,知道有什么任务是可以交给某个 tool 来处理的
// Agent 或者说 LLM 最重要的能力或者特征就是 准确
// 声明 tool 的等等格式,就是手续,目的都是为了让 LLM 能够准确使用 tool
const tools = [
{
type: "function",// tool 就是函数(用来完成某个功能) tool 是有专门的格式的
function: {
// 函数名
name: "get_closing_price",
// 函数描述,详细具体
// 描述了函数的功能,以及参数的含义和返回值
// 是 LLM 理解 tool 功能的重要依据
// LLM 能不能准确使用 tool 的核心
description: "获取股票的收盘价(单位:元),参数为股票代码,返回值为收盘价",
parameters:{
type: "object",
properties: {
stock_name: {
type: "string",
// 准确的语义 NLP
description: "股票名称"
}
},
required: ["stock_name"] // 必须传递 stock_name 参数
}
}
}
]
// 具体实现 tool 函数
function get_closing_price(stock_name) {
if(stock_name === "青岛啤酒"){
return '67.92';
}
if(stock_name === "贵州茅台"){
return '1488.20';
}
return '未找到该股票';
}
const sendMessage = async (messages) => {
return await client.chat.completions.create({
model: process.env.DASHSCOPE_MODEL,
reasoning_effort: "high",// 工作之前,先深度推理一下
messages: messages,
tools: tools,
tool_choice: "auto"
});
}
const main = async () => {
let messages = [
{
role: "system",
content: "你是一个股票领域的中文专家,请使用中文尽量帮我回答与股票相关的问题"
},
{
role: "user",
content: "青岛啤酒的收盘价是多少?"
}
];
const response = await sendMessage(messages);
const message = response.choices[0].message;
console.log(message);
}
main();
这段代码已经完成了 LLM with Tools 的前半程:
- 定义工具说明;
- 实现本地函数;
- 把
messages和tools发给模型; - 允许模型自动选择工具;
- 打印模型返回的消息。
但它还没有真正执行模型返回的 tool_calls,也没有把工具结果放回消息列表后再次调用模型。因此,当前版本是 Tool Calling 的起点,还不是完整 Agent loop。
从一次 Tool Calling 到 Agent loop,还差什么
完整过程的最后一步是"结果再交给 LLM,再 completion 一次(loop)"。把这件事翻译成程序职责,就是:
- 将模型返回的 assistant 消息追加到
messages; - 检查消息中是否存在
tool_calls; - 根据工具名找到本地函数;
- 解析模型生成的 JSON 参数;
- 执行函数并取得结果;
- 以
tool角色把执行结果追加回messages; - 再次请求模型,直到不再出现工具调用。
所以 Agent 的工具还没完成,但从流程上看,Agent 的雏形已经出现了:LLM 负责规划下一步,程序负责执行,执行结果又成为下一轮推理的 Context。这里就已经有点我们未来要讲到的非常出名的模型使用模式或提示框架: ReAct 框架的影子
text
Reasoning → Tool Call → Observation → Reasoning → ... → Final Answer
这个循环也解释了为什么一个 Agent 的能力不只取决于模型。即使大脑相同,工具集合、上下文质量、循环控制和错误处理不同,最后能完成的任务也会完全不同。
我现在怎么理解这节课
在这节课之前,我很容易把 Agent 理解成"更聪明的聊天机器人"。现在我更愿意把它理解成一个由多部分协作的程序系统:
- LLM 负责理解自然语言、推理和规划;
- Tool 负责执行确定性的外部操作;
- Context 负责提供完成任务所需的信息;
- messages 负责保存对话与执行轨迹;
- loop 负责让"思考---行动---观察"持续进行。
reasoning_effort 和 reasoning_content 让我看到模型的推理层,messages 让我看到上下文如何延续,tools 和 JSON Schema 则让我第一次把自然语言理解与 JavaScript 函数连接起来。
最关键的认知变化是:Tool Calling 不是让模型拥有了魔法,而是建立了一套模型与普通程序之间的协作协议。 模型负责输出结构化的调用意图,JavaScript 负责执行真实函数,再把结果送回模型。只要把这个循环补完整,一个只能回答问题的 LLM,就开始具备完成任务的 Agent 形态。
这也让我重新理解 Vibe Coding 时代的软件工程。会写 Prompt 只是入口,真正要把 AI 做成产品,我仍然需要掌握模块化、异步调用、数据结构、接口协议、权限、安全和错误处理。Agent 没有取代这些基本功,反而把它们重新串到了一起。