手搓 AI Agent 01:Agent 它到底是什么?

系列大纲 · 手搓 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

当前篇:第 1 篇------打通"模型能对话"这条最短路,并定下整套系列的接入层。

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

手搓 AI Agent 01:它到底是什么,以及为什么不直接用框架

很多人第一次听到"AI Agent"这个词,会以为它是一种更聪明的模型。其实不是。Agent 不是某个模型,也不是一次 API 调用,而是一种让模型按"思考---行动---观察"的循环反复运转的工作方式。本系列用 Node.js 从最底层手搓一个通用工具助手,每篇代码可跑、可测、可 checkout。这一篇先把"它到底是什么"讲透,再定下整套系列的技术地基。

00 引子:从"会聊天"到"会办事"

在讲定义之前,先建立一个直觉。

大家都用过聊天机器人:你输入一句话,它返回一句话。这个过程的全部,就是"文本进、文本出"。它像一个被关在屋子里、博览群书但不能出门的人------你递一张纸条提问,他递一张纸条回答。他不能去查今天的实时天气,不能打开你的文件,不能帮你算一笔你没告诉他的账,因为他没有任何"手"。

AI Agent 想解决的,恰恰是"让它长出手"这件事。

举一个最小的例子:查一下北京今天气温,再算它和上海的温差。这句话对人类很简单,但对纯聊天机器人是个死结------它训练数据里没有"今天"的实时天气,也没有义务去算。而一个 Agent 接到这句话,会自然地拆成几步:先用搜索工具查北京气温、查上海气温,再把两个数相减,最后把结果报回来。如果某个城市查不到,它还会换关键词再试一次。

注意这里的关键:Agent 不是一次性把答案"吐"出来,而是先想、再动、看了结果再想、再动,直到任务完成。这种"反复运转"的能力,才是 Agent 和普通补全之间真正的分界线。本篇先不讲循环怎么写(那是第 3 篇),但必须先把这个心智模型种下来,否则后面所有代码都像在隔靴搔痒。

01 定义:Agent 到底是什么

1.1 一个被讲烂但没讲清的公式

2024 年前后,社区给 Agent 下过一个共识公式:

复制代码
Agent = LLM + Memory + Tools + Planning + Action

这个公式没错,但它太"名词堆砌",初学者看完还是不知道 Agent 和聊天机器人差在哪。把五个词翻译成人话:

  • LLM(大脑):负责理解和决策。它读到你的问题,判断"现在该干嘛",也读工具返回的结果,判断"这说明什么、下一步干嘛"。
  • Memory(记事本):记住东西。短期记这一轮对话聊了什么,长期记跨会话沉淀下来的知识或偏好。没有记忆,Agent 每句话都是失忆的。
  • Tools(手脚):扩展能力边界。搜索、计算、读文件、调用外部 API,都算工具。大脑再聪明,没有手脚也只能"纸上谈兵"。
  • Planning(任务拆解):把大目标切成可执行的小步骤。比如"写一份竞品调研"会被拆成"搜集资料 → 归纳差异 → 成稿"。
  • Action(执行):真正把工具调用出去,并把结果喂回大脑。

光有这五个部件还不够。它们要串成一个会自动转的循环,才是 Agent。

1.2 真正的核心是循环,不是部件

把上面的公式升级成一句更本质的话:

调一次 chat.completions 是"会聊天";让它自己决定调工具、看见结果、再决定下一步,才是 Agent。

这句话里的"自己决定",就是循环。画成流程图就是:

复制代码
    ┌─────────────────────────────────────┐
    │                                     │
    ▼                                     │
  思考(think) ──► 行动(act: 调工具) ──► 观察(observe: 看结果)
    │                                     │
    └─────────────────────────────────────┘

每一圈:模型先想"我现在该做什么"(可能直接回答,也可能决定调某个工具);然后执行这个动作;拿到结果后,模型再想"够了没有,还差什么"。直到模型认为任务完成,才输出最终答案。

对比一下:一次性补全就像直线,输入→输出,到头了。Agent 是一条会拐弯、会回头、会多走几步的回路。第 3 篇会用代码把这个回路真正跑起来;现在只要记住------循环是 Agent 的灵魂。

1.3 三个直观场景,帮你判断"这是不是 Agent"

  • 场景 A:输入框打字,回车得到回答。→ 这是聊天机器人,不是 Agent(没有循环,没有工具)。
  • 场景 B:你说"帮我订明天去北京的机票",它先查航班、比价、问你偏好、下单。→ 这是 Agent(有工具、有循环、有自主决策)。
  • 场景 C:你问"1 到 100 的和是多少",它直接答 5050。→ 这可能是模型自身能力,不一定是 Agent------除非它真的"调用了计算器工具"而不是靠记忆算出。

判断标准只有一条:模型是否在一次任务里,自主地、多次地决定"下一步做什么",并真的去执行了外部动作。 是,就是 Agent;否,就只是个会聊天的模型。

1.4 2026 年的演进:从"五要素"到"Harness"

到 2026 年,这个公式被工程实践推进一步。Lil'Log(OpenAI 安全系统负责人 Lilian Weng)在 2026-07 的综述里,把"模型之外、支撑 Agent 跑起来的整套编排系统"命名为 Harness(意为"马具"------模型是马,Harness 是套在马身上让它拉车的东西)。

Harness 在五要素之上,额外叠加四件事:

  1. 工作流循环 :plan → execute → observe → improve,也就是第 1.2 节那个回路的工程化版本。
  2. 评估体系:自动判断 Agent 这一步做得对不对,错了怎么纠正。
  3. 权限控制:能不能删文件、能不能花钱、危险操作要不要人确认。
  4. 持久化状态:跑一半崩了,能不能从断点续上,而不是从头再来。

Claude Code、Codex 这类编码 Agent 的内核都长这样。本系列前几篇先搭五要素和循环(这是骨架),后面再逐步把 Harness 的四件套补上。

02 三个基座速览(为什么还要手搓)

既然业界已经有现成框架,为什么还要从零写?先把三个主流 TypeScript 基座摆出来对照:

基座 语言 硬约束 核心能力
Google ADK for TS TypeScript Node ≥ 20.19 代码优先、Zod 校验、多 Agent 编排、MCP、A2A 协议
Mastra TypeScript 无特殊 一套接口接 40+ 模型 provider,agents/workflows/MCP/评估
Blade Agent SDK TypeScript Node ≥ 22.14 session-first,内置 17 个标准工具 + 子 Agent + 权限沙箱

这三个都能用,但都不在本系列的主线上。手搓的目的不是"重新造轮子反对框架",而是:框架把循环、工具调度、上下文管理全封装了,你只有亲手写过一遍,框架报错的栈信息才看得懂,性能瓶颈才知道卡在哪。 框架是收尾章(第 13 篇)的对照物,不是起点。先把地基和内核自己走通,再回头看框架,会有"原来如此"的感觉。

03 本篇工程目标与接入层设计

本篇不写循环(那是第 3 篇),先打通"模型能对话"这条最短路,并定下整套系列的接入层。

接入层只有一个原则:模型无关 。DeepSeek、通义、OpenRouter、OpenAI 的 Chat Completions 接口格式一致,差异只在 base_url 和 model 名。我们把这一层封成 LLMClient,后续所有 Agent 逻辑只依赖它,换模型时内核零改动。

它提供两个能力:

  • chat():非流式,一次拿回完整文本。适合内部调用、批量任务。
  • chatStream():流式,逐 token 产出。CLI 里才有"打字机"效果,用户体验完全不同。

核心在流式解析。OpenAI 的流式响应是 SSE(Server-Sent Events):每个 data: 行里是一个 delta 片段,流结束发 data: [DONE]。解析循环很短,但有几个细节容易踩坑(缓冲区拼接、坏行容错):

ts 复制代码
const reader = body.getReader();
const decoder = new TextDecoder();
let buffer = "";
while (true) {
  const { done, value } = await reader.read();
  if (done) break;
  buffer += decoder.decode(value, { stream: true });
  const lines = buffer.split("\n");
  buffer = lines.pop() ?? "";        // 最后一行可能不完整,留到下次
  for (const line of lines) {
    const trimmed = line.trim();
    if (!trimmed.startsWith("data:")) continue;
    const payload = trimmed.slice(5).trim();
    if (payload === "[DONE]") return;
    const json = JSON.parse(payload);
    const delta = json.choices?.[0]?.delta?.content;
    if (delta) yield delta;
  }
}

非流式只是把 stream: false 发过去,取 choices[0].message.content。错误分支统一:非 2xx 直接抛出 LLMClient.chatStream failed: 状态码 响应体,不让底层 fetch 错误裸奔到业务层。

工程上还有两点:依赖锁定为 零运行时第三方包 (只靠 Node 22 内置 fetch 和 Response 流),接入 .env 的活交给 dotenv 在应用入口做,库本身保持纯净。

配套的 src/index.ts 是一个交互式 REPL 作为本篇 demo:终端输入一句(带 你: 前缀),模型流式回复一句(带 助手: 前缀),messages 数组在多轮之间累积上下文。这是后续 Agent Loop 的外壳雏形------先把"多轮对话"跑稳,下一篇再往循环里塞工具调用。

数据流长这样:

如何运行本章代码

配套代码已全部推到 GitHub,每篇对应一个 post-NN tag。跟随本章,先切到 post-01 的代码状态,再逐步运行:

bash 复制代码
# 1. 克隆仓库
git clone https://github.com/mrhuo/agent-from-scratch.git
cd agent-from-scratch

# 2. 切到本篇(第 1 篇)对应的代码快照
git checkout post-01

# 3. 安装依赖(需要 Node.js >= 22,否则内置 fetch 不可用)
npm install

# 4. 配置密钥:复制模板,填入你的 API Key
cp .env.example .env
#   然后编辑 .env,填入:
#     OPENAI_API_KEY=sk-你的key
#     BASE_URL=https://api.deepseek.com   # DeepSeek 兼容地址,注意【不带 /v1】
#     MODEL=deepseek-flash

# 5. 启动交互式对话(输入一句、模型流式回复一句,输入 exit 退出)
npm run dev

# 6. 跑单元测试(mock,无需 key / 外网)
npm test

# 7. 类型检查
npm run typecheck

说明:.env 已被 .gitignore 忽略,不会进入提交,密钥只留在本机;node_modules 同样忽略。若 npm run dev 报连接超时,先确认 .env 里 BASE_URL 没写成带 /v1 的地址(DeepSeek 不带 /v1)。

04 验证:这一章代码做了什么、实现了什么效果

代码位置:src/llm/LLMClient.ts + src/index.ts(交互式对话 REPL demo)+ tests/llm.test.ts。

做了什么 :封装了一层模型无关的对话客户端,支持非流式与流式两种调用,统一错误处理,并把密钥/端点配置收敛到 .env。

实现了什么效果,三组证据:

  1. 单元测试 4/4 通过 (npm test)。用 mock fetch 覆盖了四个分支:非流式正常解析、非 2xx 抛错、SSE 增量拼接、坏行容错。这一组证明 SSE 解析逻辑本身是对的,不依赖真实网络。
  2. 类型检查 0 errors (npm run typecheck,tsc --noEmit)。strict + noUncheckedIndexedAccess 全开,索引越界和空值都会被编译期拦下。
  3. 无密钥守卫生效(npm run dev 未配置 .env 时):进程安全退出并打印「缺少 OPENAI_API_KEY,请复制 .env.example 为 .env 并填入」,不会带着空 key 去打请求。

真实对话这一步,有两个工程坑必须说清楚,它们直接决定本地能不能跑通:

  • 坑一:DeepSeek 的 base_url 不带 /v1 。官方兼容地址是 https://api.deepseek.com(模型名 deepseek-flash),不是 OpenAI 官方那种 .../v1。配错路径会返回 401,已在仓库 .env.example 修正。
  • 坑二:dotenv 默认不覆盖已存在的环境变量 。若运行环境(本机终端 / CI / 沙箱)已注入 OPENAI_API_KEY,import "dotenv/config" 的默认行为是「已存在的变量不覆盖」,.env 里的配置会被静默忽略,请求实际打到别的 key 上,表现为 DeepSeek 返回 api key ... is invalid。正确做法是入口改用 dotenv.config({ override: true }),让本地 .env 优先。

修复后用 DeepSeek 端点实测多轮交互,输出 ◇ injected env (3) from .env 确认 .env 生效,终端输入问题与模型流式回复交替出现,多轮上下文保持:

结论:本章交付的接入层逻辑与真实调用全部验证可用------非流式、流式、错误分支、真实模型调用无一遗漏。

05 本篇埋的雷

src/index.ts 现在是交互式 REPL,能多轮输入、逐句回复,但每一轮仍是「用户说→模型答」的单向补全,没有 think → act → observe 循环,模型不能决定调用工具、看见结果、再决定下一步。所有"智能"都停在单次响应里。

下一篇(Agent Loop)补上 think → act → observe 的循环,让模型能自己决定调用工具、看见结果、再决定下一步。那是 Agent 与"高级补全"之间的真正分界线。


判断:能对话不等于是 Agent,循环才是分水岭。看点:下一篇让模型第一次"动手"。