系列大纲 · 手搓 AI Agent(Node.js / TypeScript)

本系列从零手搓一个通用工具型 Agent:能对话、能调用工具、能规划任务、能多 Agent 协作,并最终接入 MCP 生态。技术栈锁定 Node.js 22 + TypeScript,模型接入层用一套 OpenAI 兼容抽象通吃 OpenAI / DeepSeek / 通义 / OpenRouter——改 .env 即可换模型,内核零改动。每篇代码可跑、可测、可 git checkout post-NN 回溯到任意一篇起点,图文在多平台同步发布。

总目录(13 篇主线 + 1 篇收尾)

  1. 开篇:什么是 Agent,为什么不直接用框架
  2. 工程地基:Node22 + TS + 统一 LLM 客户端
  3. 心脏:Agent Loop 与 ReAct
  4. 长出手脚:Tool 系统设计
  5. 第一个真工具:搜索 / 计算 / 读文件(本文)
  6. 记忆的两种形态
  7. 会规划:Plan-and-Execute
  8. 多 Agent 协作
  9. 接外部世界:MCP 实战
  10. 让 Agent 可信:权限 / 沙箱 / 人工介入
  11. 持久化与恢复:会话、状态、崩溃续跑
  12. 上线与可观测
  13. 收尾:本系列手搓的 vs 业界 ADK / Mastra

当前篇:第 4 篇——在上一章的 Tool 系统之上,装上第一批"碰真实世界"的工具:calculator(算)、readFile(读本地文件)、searchFiles(本地检索)、webFetch(抓网页)。重点不在"再加三个函数",而在真实工具带来的三类新麻烦:权限边界、网络超时、结果截断。

配套代码仓库:https://github.com/mrhuo/agent-from-scratch (每篇对应一个 post-NN tag,git checkout post-04 即可回到本篇代码起点)

手搓 AI Agent 04:第一个真工具——搜索 / 计算 / 读文件

第 3 篇把"工具"收编成了系统:Zod 一处定义参数、注册表统一管理、错误分三类。但那个系统里只有一个工具——calculator,而且它根本不碰外部世界:输入一串表达式,内部用白名单过滤后 Function 求值,纯内存计算。

一个只会算数的 Agent,离"能用"还差得远。现实任务往往需要它:读一下我桌面的笔记、找一下之前记过的某句话、抓一下某个网页的标题。这些动作的共同点是——它们都跨越了"进程边界",去碰文件系统、碰网络。一旦越过了这道边界,第 3 篇没处理的三类麻烦就全冒出来了:

  • 权限边界:读文件能不能越权读到 .env、读到系统密码文件?
  • 网络超时:网页 30 秒不响应,Agent 是干等还是放弃?
  • 结果截断:一本书有几 MB,全塞进对话上下文会把模型撑爆。

这一篇就给 Tool 系统装上第一批真工具,并把这三类麻烦逐一解决。你会发现:加工具本身很简单(几十行),难的是让它们在"不可信输入 + 不可靠外部"的环境下不闯祸。


00 引子:为什么 calculator 不算"真工具"

想清楚一个区别:工具是在进程内算,还是跨越了进程边界?

calculator 的输入是模型给的字符串,输出也是字符串,全程在 Node 进程的内存里完成,不读文件、不发请求、不调别的进程。这种工具叫"确定性工具"——给定相同输入永远得到相同输出,不会失败(除了算错),也不需要权限。

但"读文件""抓网页"不一样:

  • 它们依赖外部状态:文件可能被删、网页可能 404、网络可能断。
  • 它们有副作用或权限要求:读文件要看你让不让它读,抓网页要消耗带宽和被反爬。
  • 它们可能很慢或很大会:一个网页几十 KB 到几 MB 不等,一个目录可能上万个文件。

所以真工具不能只关心"算什么",还要关心"失败了怎么办、太大了怎么办、越权了怎么办"。第 3 篇的 ToolError 三类分类(not_found / validation / execution)正好在这里派上用场:文件不存在是 execution 错误,超时也是 execution 错误,模型看到观察文本后自己就能换个方式重试。


01 概念:一个真工具要守住的三道防线

在动手前,先把原则钉死。每个碰外部世界的工具,至少要守住三道防线:

  1. 权限边界(最小可用范围)。能只读一个目录,就别给整个文件系统;能只读,就别给写。第 4 篇把文件类工具限制在项目下的 data/ 目录,任何 ../ 越权请求在真正读盘前就拦下。
  2. 超时与熔断(别让外部拖死主循环)。网络请求必须带超时,到点主动中断,否则 Agent 会卡死在等待里,白白烧 token。
  3. 结果截断(上下文是稀缺资源)。工具返回动辄几千字,必须按上限裁剪,只把"够模型用"的那部分喂回去。

这三条不是锦上添花,而是 Agent 接入真实能力后的生存底线。下面三个工具分别演示这三条。


02 代码:三个真工具的实现

三个工具都放在新目录 src/tools/ 下,和 calculator(在 src/agent/tools.ts)一起,经 src/tools/index.ts 聚合成 allTools 数组。它们全部沿用第 3 篇的 Tool 形态——用 Zod 定义参数,注册表负责校验与执行。

2.1 readFile:读文件,但先过"沙箱"

readFile 接收相对 data/ 的路径,返回文件文本。它最关键的不是"读",而是"读之前先问一句:这路径是不是越权了"。

// src/tools/readFile.ts(节选)
const target = resolve(DATA_ROOT, normalize("./" + args.path));
const rel = relative(DATA_ROOT, target);
// 把请求路径拼到 data/ 下后,再算相对路径;若结果以 ".." 开头或是绝对路径,
// 说明模型在试图越权(比如 "../.env"),直接抛错,绝不真的去读盘。
if (rel.startsWith("..") || isAbsolute(rel)) {
  throw new Error(`路径越权:只允许读取 data/ 目录内的文件,收到 "${args.path}"`);
}

注意 DATA_ROOT 是项目下的 data/ 目录(src/tools/root.ts 里 resolve(process.cwd(), "data"))。模型传 notes.txt,拼出来是 data/notes.txt,相对路径是 notes.txt,放行;传 ../.env,拼出来是项目根的 .env,相对路径 ../.env 以 .. 开头,拦截。这一步在 readFileSync 之前就完成,所以即使模型被诱导去读敏感文件,磁盘根本没被碰。

读到的内容还要截断:

if (content.length > MAX_CHARS) {
  return content.slice(0, MAX_CHARS) + `\n...(已截断,全文 ${content.length} 字)`;
}

MAX_CHARS 设 4000,够模型看懂一份笔记,又不会撑爆上下文。文件不存在时返回明确的"文件不存在"提示,而不是抛个底层 ENOENT 堆栈。

2.2 searchFiles:本地递归检索

"找一下我之前记过的 agent 在哪一页"——这类需求最高频,而且完全离线可复现,不需要任何搜索 API 或 key。本篇先做本地检索,网络搜索留到第 9 篇(MCP)再接。

逻辑很直白:从根目录递归遍历文本类文件,逐行匹配关键词(大小写不敏感),收集"文件路径:行号:该行内容":

// src/tools/searchFiles.ts(节选)
function walk(dir: string, visit: (file: string) => void): void {
  let entries: Dirent[];
  try {
    entries = readdirSync(dir, { withFileTypes: true });
  } catch {
    return; // 无权限或不存在的目录直接跳过,不阻断整体搜索
  }
  for (const entry of entries) {
    const full = join(dir, entry.name);
    if (entry.isDirectory()) walk(full, visit);
    else if (entry.isFile()) visit(full);
  }
}

两个细节值得记:只扫文本类扩展名(TEXT_EXT 正则过滤 .txt/.md/.json/.csv/...),跳过二进制文件避免乱码;目录读不了就跳过而不是抛错,保证一颗坏种子不拖垮整次搜索。命中数也有上限 MAX_HITS = 20,防止一次搜出上万行。

2.3 webFetch:碰网络的第一个工具,也是第一个会超时的工具

webFetch 抓一个网页,去掉 HTML 标签后返回纯文本。它是本篇里第一个真正发网络请求的工具,所以第一个要处理超时:

// src/tools/webFetch.ts(节选)
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), TIMEOUT_MS); // 10s
try {
  const res = await fetch(args.url, {
    signal: controller.signal,
    headers: { "user-agent": "agent-from-scratch/0.1" },
  });
  if (!res.ok) throw new Error(`HTTP ${res.status} ${res.statusText}`);
  const plain = stripHtml(await res.text());
  const max = args.maxChars ?? MAX_CHARS;
  if (plain.length > max) {
    return plain.slice(0, max) + ` ...(已截断,全文 ${plain.length} 字)`;
  }
  return plain;
} catch (e) {
  if (e instanceof Error && e.name === "AbortError") {
    throw new Error(`请求超时(>${TIMEOUT_MS / 1000}s):${args.url}`);
  }
  throw new Error(`抓取失败:${e instanceof Error ? e.message : String(e)}`);
} finally {
  clearTimeout(timer);
}

三段防御都在这里:

  • 超时:AbortController 设 10 秒上限,超时主动 abort(),fetch 抛 AbortError,被捕获后转成"请求超时"观察。主循环不会卡死。
  • 去标签:stripHtml 先剥

原文地址: https://www.cveoy.top/t/topic/qHWy 著作权归作者所有。请勿转载和采集!

免费AI点我,无需注册和登录