跳到主要内容

创建日期:2026-09-08 | 最近更新:2026-09-08 本系列代码用纯 Node(18+,零第三方依赖)编写,全程调用真实模型实测(本机走的是 DeepSeek 的 Anthropic 兼容端点 api.deepseek.com/anthropic,模型 deepseek-v4-flash)。所有运行输出都是真的。

用前端技术栈写 Agent:从零搭两个能跑的 Agent

一句话:Agent = 「一个循环」——把对话发给大模型 → 模型说要调用工具 → 你执行 → 把结果回填 → 再来一次,直到模型给出最终回答。不需要 Python,不需要框架,会写 fetch 的前端就能搭

本系列给你两个可直接运行的示例:一个「简单 Agent」(会调用两个小工具的问答 CLI)和 一个「复杂 Agent」(多工具 + 多步编排 + 把结果写进文件、以落盘为准的工作型 Agent)。都只依赖 Node + 一个兼容端点。

1. Agent 的最小脑图(前端视角)

你天天写的前端里其实没有「状态机」,但 Agent 就是一个朴素的状态机循环

用户问题
└─▶ while True:
reply = callModel(history) # 1. 问模型
if reply 没有 tool_use: break # 2. 它直接回答了 → 结束
for t in reply.tool_use:
result = runMyTool(t) # 3. 宿主执行工具(★ 执行权在你)
history += tool_result(t, result)

三个你必须想清楚的点:

  1. 模型只「提议」,不执行。模型返回的是「我想调用 city_population({"city":"北京"})」,真正跑这个函数的是你的代码——所以工具能做什么、能不能碰网络/文件/删库,全由你这条「白名单」决定。这是 Agent 安全模型的根基。
  2. 执行结果要回填。你不回填 tool_result,模型就没有「看到结果」的机会,无法继续。
  3. 完成以工具结果为准,不是模型嘴上说。「我写完了」不算,write_file 真返回成功、文件真在磁盘上才算——复杂 Agent 里这一点是精髓。

2. 为什么用「Anthropic 兼容端点」

我们调用的是 Messages API(Anthropic 协议),但「兼容」意味着同一个协议能连多家:Claude、DeepSeek、以及一堆网关/代理。好处:ANTHROPIC_BASE_URL + token 指到哪,代码就跑哪家,换厂商零改动。

本机实测配置(env 里已就绪,你的环境照抄这三个变量):

export ANTHROPIC_BASE_URL="https://api.deepseek.com/anthropic" # 或 Claude 的地址
export ANTHROPIC_AUTH_TOKEN="sk-…" # 你的 key
export ANTHROPIC_MODEL="deepseek-v4-flash" # 模型名

提示:别把 key 写进代码——一律从环境变量读,.gitignore 里把 key 文件挡掉。前端(浏览器)里更不能放 key,这类 Agent 的宿主跑在 Node/后端。

3. 项目结构(三个文件)

my-agent/
├─ runtime.mjs # 公共运行时:封装“调模型 + 读文本 + 工具回填”(下节完整贴出)
├─ simple.mjs # 简单 Agent
└─ complex.mjs # 复杂 Agent

4. 公共运行时 runtime.mjs(核心,逐段讲)

// runtime.mjs —— 只做一件事:把 Anthropic 兼容的对话 API 包成 await 一次。
const BASE = (process.env.ANTHROPIC_BASE_URL || 'https://api.deepseek.com/anthropic').replace(/\/+$/, '');
const KEY = process.env.ANTHROPIC_AUTH_TOKEN || '';
const MODEL = process.env.ANTHROPIC_MODEL || 'deepseek-v4-flash';

export const config = { base: BASE, model: MODEL, hasKey: !!KEY };

/** 非流式补全。messages 是 Anthropic 格式;tools 会附上工具声明。 */
export async function complete(messages, { tools = [], maxTokens = 2048, system } = {}) {
const body = {
model: MODEL, max_tokens: maxTokens, messages,
...(system ? { system } : {}),
...(tools.length ? { tools: tools.map((t) => ({
name: t.name, description: t.description, input_schema: t.input_schema })) } : {}),
};
const res = await fetch(`${BASE}/v1/messages`, {
method: 'POST',
headers: { 'content-type': 'application/json', 'x-api-key': KEY, 'anthropic-version': '2023-06-01' },
body: JSON.stringify(body),
});
const json = await res.json();
if (!res.ok) throw new Error(`API ${res.status}: ${JSON.stringify(json).slice(0, 300)}`);
return json;
}

/** 把 assistant 回复里的纯文本抽出来(过滤掉 thinking / tool_use 块) */
export const textOf = (m) => (m?.content ?? []).filter((b) => b.type === 'text').map((b) => b.text).join('');
export const thinkOf = (m) => (m?.content ?? []).filter((b) => b.type === 'thinking').map((b) => b.thinking ?? '').join('\n');
export const toolUses = (m) => (m?.content ?? []).filter((b) => b.type === 'tool_use');

export const userMsg = (text) => ({ role: 'user', content: text });
export const modelMsg = (m) => ({ role: 'assistant', content: m.content }); // 回放整个 assistant(含 tool_use)

逐个看懂它(这三个知识点能cover 90% 的 Agent 底层):

作用
complete()发 POST;返回体里有 content: [](块数组)和 stop_reasoncontent 里可能有 text / thinking / tool_use 三种块
textOf / thinkOf / toolUses从块数组里分别捞出「正文」「思考」「工具调用」——推理模型(如 deepseek-v4-flash)会先给一段 thinking,你必须学会跳过它只取 text
modelMsg(m)把 assistant 的完整回复(含 tool_use 块)原样追加回历史——这是协议要求:要回放 tool_use,它才有对应的 tool_result 位置

连通性自测(真实输出)

import { complete, textOf, userMsg } from './runtime.mjs';
const r = await complete([userMsg('请只回复两个字:pong')], { maxTokens: 64 });
console.log('text =', textOf(r));

实测(DeepSeek,推理模型):

text = pong

(返回的 content 里其实还有一段 thinking,被 textOf 过滤掉了——后面简单 Agent 会把思考也打出来给你看。)

5. 写工具:一份「输入声明 + 一个 run 函数」

每个工具就两件东西:给模型看的 JSON Schema 输入声明 + 宿主执行的 run 函数

const cityPopulation = {
name: 'city_population',
description: '查询某城市的人口(万人)',
input_schema: {
type: 'object',
properties: { city: { type: 'string', description: '城市名,如 北京/上海…' } },
required: ['city'],
},
run: (args) => CITY_DB[args.city] ?? `没有「${args.city}」的数据`,
};

白名单即安全边界:你只把 city_populationwrite_file 这类工具交给模型,它就没有访问网络/删库的可能。想让它更强 → 加工具;想让它更安全 → 不给工具。

6. 本系列路线

你会得到
1 简单 Agent完整可跑的 tool-calling 循环:模型提议→宿主执行→回填→总结(含并行工具、thinking 处理)
2 复杂 Agent多工具 + 多步编排 + 文件即记忆 + 以落盘为准;迈向 MCP/记忆库的进阶点
3 时序与本质用 mini-agent 真实抓包日志画清非流式/流式时序模型,剖析「智能体 = 消息序列的编排器」
4 结算式长期 Agent 架构设计文:借会计「凭证—账—表、结账前对账、期初结转」做有界、可信、可审计的长期上下文

关联:想用现成框架而不再手搓循环,看本站 pi-agent 系列(它把这套循环做成了库);想理解「工具=协议化能力」→ MCP

动手

  1. 配好三个 env(§2);2. 建目录存好 runtime.mjs;3. 跑上面的连通性自测看到 pong;4. 进下一篇。

自测

  1. Agent 的 while 循环里,什么条件代表「该结束了」?
  2. 谁来决定工具能干什么?模型能不能绕过去?
  3. 为什么必须把 tool_result 回填给模型?
  4. 推理模型的回复里除了 text 还常有什么块?取正文要过滤什么?
  5. key 应该放哪?为什么不能进浏览器/进代码库?