零基础 · 一个文件 · 对话、流式与工具

动手写一个
终端 Agent

目标只有一个:在终端里打字,AI 能接着聊。代码尽量短,看懂就能改。

适合零基础 Node.js 一个文件 全程终端交互
你: 用一句话解释 Agent
AI: 能记住上下文、在循环里反复调用模型的程序。
你: 那和一次问答有什么不同?
AI: 一次问答没有记忆;Agent 把每轮对话都留下来。

COURSE MAP

四步:调用模型、循环对话、流式输出,再给 Agent 一双手

先做出能聊天的终端程序,再加入读取和写入文件的工具。最后在终端里运行、检查、调整提示词,亲手迭代你的 Agent。

STEP 01
问一次发 HTTP 请求,打印 AI 的一句话。
STEP 02
聊下去用 readline 循环读输入,把历史塞回去。
STEP 03
流式打字打开 stream,边收 token 边往终端写。
STEP 04
使用工具让模型决定何时读取或写入项目文件。

BEFORE CLASS

准备 4 件事

需要已安装 Node.js LTS 和 Git。作业全部做完再提交一次。

  1. 确认环境和文件夹
    Terminal
    node --version
    git --version
    mkdir my-agent
    cd my-agent
    git init
    验证 用 Cursor 打开 my-agent,终端里 pwd 末尾是这个文件夹。
  2. 新建 .env,不要把密钥写进代码
    .env
    OPENAI_API_KEY=sk-你的key
    OPENAI_BASE_URL=https://api.openai.com/v1
    OPENAI_MODEL=gpt-4o-mini
  3. 新建 .gitignore,只写一行
    .gitignore
    .env
    验证 git check-ignore -v .env 能看到忽略规则。先不要 commit。
  4. 新建文件都用左侧资源管理器 点“新建文件”,贴代码,Command + S / Ctrl + S 保存。运行一律在 Cursor 终端。
01

调用 LLM

大约 20 行:POST 一次,打印回复。

目标

看懂一次调用你发 JSON,模型回一段文字。这就是后面循环要反复做的事。

新建 step1.mjs,整份贴进去即可。

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);
node --env-file=.env step1.mjs

如果报错:先看终端里的完整报错。常见原因是 Key 没读到、模型名不对,或 OPENAI_BASE_URL 少了 /v1

练习

  1. content,换成你自己的问题再跑一次。
  2. 在消息数组最前面加一条 { role: "system", content: "用很短的句子回答。" },看回复会不会变短。
02

终端里循环对话

这就是 Agent:读你的输入 → 调用模型 → 记住历史 → 再读下一句。

目标

做出能聊的 Agent核心不是新算法,是一个 while 和一个消息数组。

新建 agent.mjs。比第一步多两样:把请求收成函数;用 readline 在终端里问你。

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 }),
  });
  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();
node --env-file=.env agent.mjs

这段代码在干什么?

  • ask 就是第一步那次 HTTP 调用,只是可以反复用。
  • messages 装着整段对话。下一轮模型能看到上一轮。
  • 输入 exit 或空回车就结束。

练习

  1. 先问“我叫小明”,再问“我叫什么?”,确认它记得。
  2. 改 system 提示,让它每次用三个字以内回答。
03

流式输出

第二步会等整段 JSON 到齐再打印。加上 stream: true,字会一个接一个出来。

目标

边生成边显示模型每次只给一小段文字。你拼起来就是完整回复,同时立刻写到终端。

用下面这份覆盖 agent.mjs。循环还在,打印改成边收边写。

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();
node --env-file=.env agent.mjs

这段代码在干什么?

  • stream: true 之后,响应不再是一份完整 JSON,而是一串 data: ... 行。
  • 每一行里的 delta.content 是新吐出来的几个字。process.stdout.write 立刻打印,不会先空等。
  • buf 用来拼被切断的半行,避免 JSON.parse 读到残缺数据。
  • 完整回复仍要推进 messages,下一轮模型才能看见刚才说的话。

如果卡住或报 JSON 错:确认 OPENAI_BASE_URL 仍带 /v1。有的代理不支持 stream,这时先退回第二步的 await res.json()

练习

  1. 问一个会长一点的问题,确认字是陆续出现的,不是等几秒突然整段出来。
  2. process.stdout.write("AI: ") 改成先打印 AI: 再空一行,看终端排版怎么变。
04

给 Agent 文件工具

模型不直接碰文件:它提出调用,你的 Node.js 程序负责检查和执行。

目标

让 Agent 能做事模型选择工具,程序执行工具,再把结果交回模型。

工具循环 输入任务 → 模型请求工具 → 程序执行 → 返回结果 → 模型继续判断 → 最终回答

安全边界:下面的工具只能读写你运行命令时所在的项目目录。绝对路径、../ 越界路径,以及指向项目外的符号链接都会被拒绝。

用下面这份覆盖 agent.mjs。这一步先不做流式输出,只专注看清工具调用。

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();
node --env-file=.env agent.mjs

这段代码在干什么?

  • tools 只告诉模型有哪些能力;真正读写文件的是本地函数。
  • safePath 先做相对路径检查,再用 realpath 确认真实位置仍在项目目录内。
  • 模型返回 tool_calls 后,程序执行工具,并用 role: "tool" 把结果放回消息数组。
  • runAgent 最多循环 6 次,防止模型一直调用工具而不结束。

在终端里迭代 Agent

  1. 输入“读取 report.md,用三句话概括”,确认 Agent 能看到文件内容。
  2. 输入“保留原文,在 report.md 末尾补一段学习反思”,确认文件被修改。
  3. git diff -- report.md
    检查 Agent 到底改了什么。
  4. 把 system 提示改成“写入前先说明计划,不得删除原文”,重新运行并比较结果。
  5. 继续调整提示词或任务描述,直到 git diff 中的修改符合你的预期。

BIG PICTURE

Agent 就是这四样

消息数组记住说过的话
+
LLM 调用判断下一步做什么
+
文件工具真正读取和写入
+
终端循环运行、检查、再调整
Agent 循环 读输入 → 调用模型 → 执行工具 → 回传结果 → 最终回答 → 检查文件 → 调整后再试

HAND IN

交这些就够

一个文件夹。代码里不要有真实 API Key。仓库里不能有 .env。做完后提交一次 Git。

  • step1.mjs
    第一次调用。
  • agent.mjs
    文件工具 Agent,能读写项目文件。
  • report.md
    下面四题,每题 3 句话左右。

report.md

  1. 终端 Agent 和网页里问一次 AI,差别在哪?
  2. 等整段回复再打印,和流式输出,使用时差在哪?
  3. 模型描述工具、本地执行工具、返回工具结果,三者各自做什么?
  4. 你如何通过运行、检查和修改提示词迭代 Agent?
全部做完后提交一次
Terminal
git diff -- report.md
git add .gitignore step1.mjs agent.mjs report.md
git commit -m "feat: 完成文件工具 Agent"
git log --oneline

READY

打开终端,让 Agent 开始做事。

让它读取和修改 report.md,用 git diff 检查结果,再调整提示词继续迭代。