COURSE MAP
四步:调用模型、循环对话、流式输出,再给 Agent 一双手
先做出能聊天的终端程序,再加入读取和写入文件的工具。最后在终端里运行、检查、调整提示词,亲手迭代你的 Agent。
BEFORE CLASS
准备 4 件事
需要已安装 Node.js LTS 和 Git。作业全部做完再提交一次。
-
确认环境和文件夹
node --version git --version mkdir my-agent cd my-agent git init验证 用 Cursor 打开my-agent,终端里pwd末尾是这个文件夹。 -
新建 .env,不要把密钥写进代码
OPENAI_API_KEY=sk-你的key OPENAI_BASE_URL=https://api.openai.com/v1 OPENAI_MODEL=gpt-4o-mini -
新建 .gitignore,只写一行
.env验证git check-ignore -v .env能看到忽略规则。先不要 commit。 -
新建文件都用左侧资源管理器
点“新建文件”,贴代码,
Command + S/Ctrl + S保存。运行一律在 Cursor 终端。
调用 LLM
大约 20 行:POST 一次,打印回复。
看懂一次调用你发 JSON,模型回一段文字。这就是后面循环要反复做的事。
新建 step1.mjs,整份贴进去即可。
const KEY = process.env.OPENAI_API_KEY;
const URL = process.env.OPENAI_BASE_URL || "https://api.openai.com/v1";
const MODEL = process.env.OPENAI_MODEL || "gpt-4o-mini";
const res = await fetch(`${URL}/chat/completions`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${KEY}`,
},
body: JSON.stringify({
model: MODEL,
messages: [{ role: "user", content: "用一句话解释什么是 API" }],
}),
});
const data = await res.json();
console.log(data.choices[0].message.content);
如果报错:先看终端里的完整报错。常见原因是 Key 没读到、模型名不对,或 OPENAI_BASE_URL 少了 /v1。
练习
- 改
content,换成你自己的问题再跑一次。 - 在消息数组最前面加一条
{ role: "system", content: "用很短的句子回答。" },看回复会不会变短。
终端里循环对话
这就是 Agent:读你的输入 → 调用模型 → 记住历史 → 再读下一句。
做出能聊的 Agent核心不是新算法,是一个 while 和一个消息数组。
新建 agent.mjs。比第一步多两样:把请求收成函数;用 readline 在终端里问你。
import readline from "node:readline/promises";
const KEY = process.env.OPENAI_API_KEY;
const URL = process.env.OPENAI_BASE_URL || "https://api.openai.com/v1";
const MODEL = process.env.OPENAI_MODEL || "gpt-4o-mini";
async function ask(messages) {
const res = await fetch(`${URL}/chat/completions`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${KEY}`,
},
body: JSON.stringify({ model: MODEL, messages }),
});
const data = await res.json();
return data.choices[0].message;
}
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout,
});
const messages = [{ role: "system", content: "你是终端AI助手。" }];
console.log("开始聊天,输入 exit 退出");
while (true) {
const q = (await rl.question("你: ")).trim();
if (!q || q === "exit") break;
messages.push({ role: "user", content: q });
const reply = await ask(messages);
messages.push(reply);
console.log("AI:", reply.content);
}
rl.close();
这段代码在干什么?
ask就是第一步那次 HTTP 调用,只是可以反复用。messages装着整段对话。下一轮模型能看到上一轮。- 输入
exit或空回车就结束。
练习
- 先问“我叫小明”,再问“我叫什么?”,确认它记得。
- 改 system 提示,让它每次用三个字以内回答。
流式输出
第二步会等整段 JSON 到齐再打印。加上 stream: true,字会一个接一个出来。
边生成边显示模型每次只给一小段文字。你拼起来就是完整回复,同时立刻写到终端。
用下面这份覆盖 agent.mjs。循环还在,打印改成边收边写。
import readline from "node:readline/promises";
const KEY = process.env.OPENAI_API_KEY;
const URL = process.env.OPENAI_BASE_URL || "https://api.openai.com/v1";
const MODEL = process.env.OPENAI_MODEL || "gpt-4o-mini";
async function ask(messages) {
const res = await fetch(`${URL}/chat/completions`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${KEY}`,
},
body: JSON.stringify({ model: MODEL, messages, stream: true }),
});
process.stdout.write("AI: ");
let text = "";
let buf = "";
for await (const chunk of res.body) {
buf += new TextDecoder().decode(chunk);
const lines = buf.split("\n");
buf = lines.pop();
for (const line of lines) {
if (!line.startsWith("data: ")) continue;
const data = line.slice(6).trim();
if (!data || data === "[DONE]") continue;
const piece = JSON.parse(data).choices[0]?.delta?.content;
if (!piece) continue;
text += piece;
process.stdout.write(piece);
}
}
process.stdout.write("\n");
return { role: "assistant", content: text };
}
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout,
});
const messages = [{ role: "system", content: "你是终端助手,回答要短。" }];
console.log("开始聊天,输入 exit 退出");
while (true) {
const q = (await rl.question("你: ")).trim();
if (!q || q === "exit") break;
messages.push({ role: "user", content: q });
messages.push(await ask(messages));
}
rl.close();
这段代码在干什么?
stream: true之后,响应不再是一份完整 JSON,而是一串data: ...行。- 每一行里的
delta.content是新吐出来的几个字。process.stdout.write立刻打印,不会先空等。 buf用来拼被切断的半行,避免JSON.parse读到残缺数据。- 完整回复仍要推进
messages,下一轮模型才能看见刚才说的话。
如果卡住或报 JSON 错:确认 OPENAI_BASE_URL 仍带 /v1。有的代理不支持 stream,这时先退回第二步的 await res.json()。
练习
- 问一个会长一点的问题,确认字是陆续出现的,不是等几秒突然整段出来。
- 把
process.stdout.write("AI: ")改成先打印AI:再空一行,看终端排版怎么变。
给 Agent 文件工具
模型不直接碰文件:它提出调用,你的 Node.js 程序负责检查和执行。
让 Agent 能做事模型选择工具,程序执行工具,再把结果交回模型。
安全边界:下面的工具只能读写你运行命令时所在的项目目录。绝对路径、../ 越界路径,以及指向项目外的符号链接都会被拒绝。
用下面这份覆盖 agent.mjs。这一步先不做流式输出,只专注看清工具调用。
import { readFile, writeFile, realpath, lstat } from "node:fs/promises";
import path from "node:path";
import readline from "node:readline/promises";
const KEY = process.env.OPENAI_API_KEY;
const URL = process.env.OPENAI_BASE_URL || "https://api.openai.com/v1";
const MODEL = process.env.OPENAI_MODEL || "gpt-4o-mini";
const ROOT = process.cwd();
let rootRealPath;
const tools = [
{
type: "function",
function: {
name: "read_file",
description: "读取当前项目目录中的 UTF-8 文本文件",
parameters: {
type: "object",
properties: {
path: { type: "string", description: "相对当前项目目录的文件路径" },
},
required: ["path"],
},
},
},
{
type: "function",
function: {
name: "write_file",
description: "把完整文本写入当前项目目录中的文件",
parameters: {
type: "object",
properties: {
path: { type: "string", description: "相对当前项目目录的文件路径" },
content: { type: "string", description: "要写入的完整文件内容" },
},
required: ["path", "content"],
},
},
},
];
function checkRelativePath(filePath) {
if (path.isAbsolute(filePath)) throw new Error("只允许相对路径");
const fullPath = path.resolve(ROOT, filePath);
const relative = path.relative(ROOT, fullPath);
if (relative.startsWith("..") || path.isAbsolute(relative)) {
throw new Error("不能访问项目目录之外的文件");
}
return fullPath;
}
function checkInsideRoot(realTarget, root) {
const relative = path.relative(root, realTarget);
if (relative.startsWith("..") || path.isAbsolute(relative)) {
throw new Error("不能访问项目目录之外的文件");
}
}
async function getRootRealPath() {
if (!rootRealPath) {
rootRealPath = await realpath(ROOT);
}
return rootRealPath;
}
async function safePath(filePath, forWrite = false) {
const fullPath = checkRelativePath(filePath);
const root = await getRootRealPath();
if (forWrite) {
try {
const stat = await lstat(fullPath);
if (stat.isSymbolicLink()) {
throw new Error("不能通过符号链接写入文件");
}
} catch (error) {
if (error.code !== "ENOENT") throw error;
}
const realParent = await realpath(path.dirname(fullPath));
checkInsideRoot(realParent, root);
return fullPath;
}
const realTarget = await realpath(fullPath);
checkInsideRoot(realTarget, root);
return realTarget;
}
async function runTool(name, args) {
if (name === "read_file") {
return readFile(await safePath(args.path), "utf8");
}
if (name === "write_file") {
await writeFile(await safePath(args.path, true), args.content, "utf8");
return `已写入 ${args.path}`;
}
throw new Error(`未知工具:${name}`);
}
async function ask(messages) {
const res = await fetch(`${URL}/chat/completions`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${KEY}`,
},
body: JSON.stringify({ model: MODEL, messages, tools }),
});
if (!res.ok) throw new Error(`API ${res.status}: ${await res.text()}`);
const data = await res.json();
return data.choices[0].message;
}
async function runAgent(messages) {
for (let round = 0; round < 6; round += 1) {
const reply = await ask(messages);
messages.push(reply);
if (!reply.tool_calls?.length) return reply.content || "任务完成";
for (const call of reply.tool_calls) {
let result;
try {
const args = JSON.parse(call.function.arguments);
result = await runTool(call.function.name, args);
} catch (error) {
result = `工具执行失败:${error.message}`;
}
messages.push({
role: "tool",
tool_call_id: call.id,
content: result,
});
}
}
return "工具调用次数过多,请把任务说得更具体。";
}
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout,
});
const messages = [{
role: "system",
content: "你是文件助手。写入前先读取原文件,不要删除原有内容。",
}];
console.log("开始聊天,输入 exit 退出");
while (true) {
const q = (await rl.question("你: ")).trim();
if (!q || q === "exit") break;
messages.push({ role: "user", content: q });
try {
console.log("AI:", await runAgent(messages));
} catch (error) {
console.error("错误:", error.message);
}
}
rl.close();
这段代码在干什么?
tools只告诉模型有哪些能力;真正读写文件的是本地函数。safePath先做相对路径检查,再用realpath确认真实位置仍在项目目录内。- 模型返回
tool_calls后,程序执行工具,并用role: "tool"把结果放回消息数组。 runAgent最多循环 6 次,防止模型一直调用工具而不结束。
在终端里迭代 Agent
- 输入“读取 report.md,用三句话概括”,确认 Agent 能看到文件内容。
- 输入“保留原文,在 report.md 末尾补一段学习反思”,确认文件被修改。
- git diff -- report.md检查 Agent 到底改了什么。
- 把 system 提示改成“写入前先说明计划,不得删除原文”,重新运行并比较结果。
- 继续调整提示词或任务描述,直到
git diff中的修改符合你的预期。
BIG PICTURE
Agent 就是这四样
HAND IN
交这些就够
一个文件夹。代码里不要有真实 API Key。仓库里不能有 .env。做完后提交一次 Git。
step1.mjs
第一次调用。agent.mjs
文件工具 Agent,能读写项目文件。report.md
下面四题,每题 3 句话左右。
report.md
- 终端 Agent 和网页里问一次 AI,差别在哪?
- 等整段回复再打印,和流式输出,使用时差在哪?
- 模型描述工具、本地执行工具、返回工具结果,三者各自做什么?
- 你如何通过运行、检查和修改提示词迭代 Agent?
git diff -- report.md
git add .gitignore step1.mjs agent.mjs report.md
git commit -m "feat: 完成文件工具 Agent"
git log --oneline