从零跑通一个 Agent
Agent 的本质就是一个循环:给无状态的 LLM 套上循环、工具和终止条件,三十多行 Node.js 代码就能跑通一个最小可用的 Agent。
我们在代码里直接调一次 LLM API,问“北京今天的天气怎么样”,API 大概率会返回“我没有实时数据”,到此对话就结束了。
但你打开豆包或 ChatGPT 问同样的问题,它现在就能直接告诉你今天多少度(这些工具刚开始也是面对着同样的问题)。
这其中的区别在哪?是模型变聪明了吗?不是的,是后者在模型外面套了一层壳,使得模型能够被允许多走几步路,即,先判断需要查天气,再去调天气 API,拿到结果后再组织语言回答你。
这一层壳,就是 Agent。
那么在这一篇,我们就来亲手实现它,不用怕,Agent 的本质其实就是一个循环,非常简单。
环境准备
先确认你的 Node.js 版本,需要 22 及以上:
node -v然后初始化项目,安装依赖:
mkdir agent-series && cd agent-series
npm init -y
npm install openai dotenv在项目根目录创建 .env 文件,填入你的 OpenAI API Key:
OPENAI_API_KEY=你的key
OPENAI_BASE_URL=https://api.deepseek.com/v1
MODEL=deepseek-chat只要支持 OpenAI 协议的模型都能用,换模型只需要改这三个值。比如用通义千问就把 OPENAI_BASE_URL 改成 https://dashscope.aliyuncs.com/compatible-mode/v1,`MODEL` 改成 qwen-plus。
再创建一个 index.ts 文件,我们所有的代码都写在这里。
提前声明一点,直接运行 TypeScript 需要 --experimental-strip-types 参数(Node 22.6+ 支持)。如果你的 Node 是 22.18 及以上版本,type stripping 已默认开启,可以直接 node index.ts,不用加参数。如果版本更低跑不了,用 npx tsx index.ts 替代。
先和模型握个手
我们从最熟悉的单次调用开始。在 index.ts 里写:
import OpenAI from "openai";
import "dotenv/config";
const client = new OpenAI({
baseURL: process.env.OPENAI_BASE_URL,
apiKey: process.env.OPENAI_API_KEY,
});
const res = await client.chat.completions.create({
model: process.env.MODEL as string,
messages: [{ role: "user", content: "你好" }],
});
console.log(res.choices[0].message.content);运行:
node --experimental-strip-types index.ts
你好!很高兴见到你。我是Z.ai训练的GLM大语言模型,随时准备为你提供帮助。
有什么我能为你解答或协助的问题吗?无论是需要信息、创意还是简单的聊天,我都很乐意与你交流。你会看到模型回复的这一段话,当然,每个模型输出都不一样。
这只是一次函数调用,输入进去,输出出来,整个流程就结束。因为模型本身是无状态的,它不记得我们上一句说了些什么,也不会主动做任何事。
而 Agent 要做的事,就是给这个无状态的函数,套上一层壳。
Agent 和 Chatbot 的区别
在加代码之前,我们需要先搞清楚一个核心问题。
Chatbot 的模式是一问一答。每次调用都是独立的,如果要记住上下文,是我们开发者手动把历史消息拼进 messages 数组里的。模型本身是被动的进行回答,也就是说用户不问,它就不答。
Agent 的模式不一样。它有一个目标,然后在一个循环里自己决定下一步做什么:可能是直接回答你,也可能是调用一个工具,还可能是继续思考。直到它判断任务完成了,循环才算结束。
可以这么认为:
Agent = LLM + 循环 + 工具 + 终止条件
这里面最核心的是循环。没有循环,模型永远是被动的回答问题,而有了循环,它才会开始主动推进任务。我们后续所有的文章,像什么工具调用、记忆、规划、多 Agent,本质上都是在这个循环上做文章。
套上循环
现在我们把单次调用改成循环。把 index.ts 改成:
import OpenAI from "openai";
import "dotenv/config";
const client = new OpenAI({
baseURL: process.env.OPENAI_BASE_URL,
apiKey: process.env.OPENAI_API_KEY,
});
const messages = [
{ role: "user", content: "帮我规划一下今天的学习计划" },
];
const MAX_ROUNDS = 5;
for (let round = 0; round < MAX_ROUNDS; round++) {
const res = await client.chat.completions.create({
model: process.env.MODEL,
messages,
});
const reply = res.choices[0].message.content;
console.log(`[第 ${round + 1} 轮] ${reply}`);
// 把模型回复加入上下文,下一轮它就能"看到"自己说过的话
messages.push({ role: "assistant", content: reply });
// 终止条件:模型说"完成"就停,或者达到最大轮数
if (reply.includes("完成") || round === MAX_ROUNDS - 1) break;
}注意看代码里面的这个 prompt:我特意写了“直接输出计划,不要问我任何问题”。这是因为在非交互式脚本里,模型如果反问你,如果你没法回答,程序就会卡住。
运行这段代码,就会看到模型一轮一轮地输出,直到它自己说“完成”,或者跑满 5 轮。
node index.ts
[第 1 轮] # 今日 Node.js 学习计划(4 小时)
## 第一阶段:环境搭建与基础(0:00–1:00)
- 安装 Node.js(选择 LTS 长期支持版),用 `node -v` 验证安装成功
- 了解 Node.js 是什么:基于 V8 引擎的 JavaScript 运行时,可脱离浏览器运行
- 用 REPL 交互环境跑几行代码熟悉一下
- 学习模块系统:CommonJS(`require` / `module.exports`)与 ESM(`import` / `export`)
- **小练习**:写一个 `hello.js`,用 `node hello.js` 运行
## 第二阶段:核心模块与 npm(1:00–2:00)
- 学习 `fs` 模块:同步与异步读写文件
- 学习 `path` 模块:路径拼接与解析
- npm 入门:`npm init` 初始化项目、读懂 `package.json`、安装一个依赖
- **小练习**:写一个脚本,统计指定文本文件的行数并输出
☕ **休息 10 分钟(2:00–2:10)**
## 第三阶段:HTTP 与 Express(2:10–3:10)
- 用原生 `http` 模块创建 Web 服务器,理解请求(req)与响应(res)
- 手写简单路由:访问 `/` 和 `/about` 返回不同内容
- 安装 Express,学习基本路由写法和中间件概念
- **小练习**:用 Express 写一个返回 JSON 数据的接口
## 第四阶段:实战项目与总结(3:10–4:00)
- **综合实战**:写一个简单的待办事项 REST API,支持查看列表、添加、删除
- 遇到问题先查官方文档(nodejs.org)或搜索,培养排错能力
- 最后 15 分钟:回顾今天的笔记,列出掌握的知识点和没弄懂的疑问(明天优先解决)
## 温馨提示
- 全程边学边敲代码,不要只看视频或文章
- 把所有代码放在同一个文件夹里,方便明天复习
- 看不懂的概念先记下来继续往后学,往往后面就自然理解了
完成这已经是一个最原始的 Agent 了,它能多轮推理,然后能自己决定什么时候停下来。
为什么必须有最大轮数?
LLM 是概率模型,它可能陷入死循环,比如反复说“让我再想想”,或者每轮都输出类似的内容。如果没有终止条件,你的程序会一直跑,token 费用会一直涨,如果你在使用相关 Agent 工具的时候仔细阅读思考,你就会发现模型有些时候会一直“wait——”“等等,xxx”“可能xxx”,就是陷入了这种重复。
任何 Agent 都必须有终止条件,这是工程常识,而不是可选项。常见的终止条件有三种:
-
模型输出特定信号(比如“完成”、“任务结束”)
-
达到最大轮数(兜底,防止死循环)
-
检测到重复输出(模型开始车轱辘话来回说了)
在实际项目里,通常是这三种组合使用。
加上工具
现在这个 Agent 还只会说,不会做事情。这一节,我们让它能调用工具,才算真正的搞出了一个 Agent。
考虑加一个最简单的工具:获取当前时间。为了聚焦循环这个本质,我先用最简单的方式,约定模型输出特定标记来表示要调用工具。等到下一篇我们再正儿八经的讲一下 Function Calling。
import OpenAI from "openai";
import "dotenv/config";
const client = new OpenAI({
baseURL: process.env.OPENAI_BASE_URL,
apiKey: process.env.OPENAI_API_KEY,
});
// 定义一个工具
function getCurrentTime() {
return new Date().toLocaleString("zh-CN");
}
const messages = [
{
role: "system",
content:
"你是一个助手。如果需要知道当前时间,回复 [GET_TIME],我会把时间发给你。",
},
{ role: "user", content: "现在几点了?" },
];
const MAX_ROUNDS = 5;
for (let round = 0; round < MAX_ROUNDS; round++) {
const res = await client.chat.completions.create({
model: process.env.MODEL,
messages,
});
const reply = res.choices[0].message.content;
console.log(`[第 ${round + 1} 轮] ${reply}`);
// 检测工具调用
if (reply.includes("[GET_TIME]")) {
const time = getCurrentTime();
messages.push({ role: "assistant", content: reply });
messages.push({ role: "user", content: `当前时间是:${time}` });
continue; // 继续下一轮,让模型基于工具结果继续回答
}
// 没有工具调用,就是最终回答
messages.push({ role: "assistant", content: reply });
break;
}运行一下。你会看到类似这样的输出:
[第 1 轮] [GET_TIME]
[第 2 轮] 现在是 2026年10月30日 12:39:27,中午时分。还有其他需要帮忙的吗?模型先输出 [GET_TIME],我们的代码检测到这个标记,执行 getCurrentTime(),把结果塞回上下文,然后继续循环。下一轮模型拿到时间,给出最终回答。
这就是一个完整的最小 Agent。它的工作流程是:
感知(用户问题)→ 思考(决定要不要调工具)→ 行动(调用工具)→ 观察(拿到结果)→ 回答
我们写的这个循环,在学术上也有个名字,叫 ReAct(Reasoning + Acting):
-
Reason:模型思考下一步该做什么
-
Act:执行动作(调用工具)
-
Observe:观察工具返回的结果
-
重复,直到任务完成
这是最基础、最通用的 Agent 循环。后面的 Plan-and-Execute、Reflexion、多 Agent 协作,都可以看作在这个循环上叠加不同的 BUFF 得到的变体。我们这里用三十多行代码实现了它的核心。
记住这个循环,后面所有的文章都是在它上面做扩展。
恭喜,你完成了第一个属于你自己的 Agent!
常见问题
-
无限循环
现象:Agent 转个不停,token 哗哗烧,程序停不下来。
原因:没有终止条件,或者终止条件太宽松。比如你只判断了
reply.includes("完成"),但模型可能说“好的,任务完成了”之外的话来结束。解法:必须设置
MAX_ROUNDS作为兜底。同时可以检测最近几轮的输出是否重复,如果重复就强制终止。 -
上下文爆炸
现象:跑几轮就报错
context_length_exceeded。原因:我们的代码每轮都把
messages完整地发给模型,token 只增不减。轮数多了,或者每轮内容长了,就会超过模型的上下文窗口限制。解法:这就是第 3 篇要讲的记忆系统要解决的问题。现在只需要记住:上下文是稀缺资源,不能无限往里塞东西。
-
模型“幻觉”调用工具
现象:模型输出了
[GET_WEATHER],但你根本没定义这个工具。原因:我们只在 system prompt 里说了
[GET_TIME],但模型是概率生成的,它可能发明出新的标记。尤其是问题和时间无关时,它可能胡乱输出。解法:下一篇要讲的 Function Calling(结构化输出) 就是专门解决这个问题的。它用 schema 约束模型只能调用你定义好的工具,从根本上杜绝幻觉调用。
总结
今天我们做到了三件事:
-
跑通了一个最小 Agent,它能多轮对话、能调用工具、能自己决定什么时候停
-
理解了 Agent 的本质,就是一个循环,加上工具,加上终止条件
-
实现了 ReAct 模式的核心,感知、思考、行动、观察,循环往复
现在我们手里这个三十多行的程序,就是所有复杂 Agent 的起点。
下一篇预告
今天我们用 [GET_TIME] 这种约定标记来调用工具,简单但脆弱,因为模型可能不按约定出牌,可能发明不存在的工具。
下一篇我们讲 Function Calling(结构化输出),用 schema 把工具调用变成接口契约,让 Agent 真正可靠起来。