创建日期: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)
三个你必须想清楚的点:
- 模型只「提议」,不执行。模型返回的是「我想调用
city_population({"city":"北京"})」,真正跑这个函数的是你的代码——所以工具能做什么、能不能碰网络/文件/删库,全由你这条「白名单」决定。这是 Agent 安全模型的根基。 - 执行结果要回填。你不回填
tool_result,模型就没有「看到结果」的机会,无法继续。 - 完成以工具结果为准,不是模型嘴上说。「我写完了」不算,
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_reason。content 里可能有 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_population、write_file这类工具交给模型,它就没有访问网络/删库的可能。想让它更强 → 加工具;想让它更安全 → 不给工具。
6. 本系列路线
| 篇 | 你会得到 |
|---|---|
| 1 简单 Agent | 完整可跑的 tool-calling 循环:模型提议→宿主执行→回填→总结(含并行工具、thinking 处理) |
| 2 复杂 Agent | 多工具 + 多步编排 + 文件即记忆 + 以落盘为准;迈向 MCP/记忆库的进阶点 |
| 3 时序与本质 | 用 mini-agent 真实抓包日志画清非流式/流式时序模型,剖析「智能体 = 消息序列的编排器」 |
| 4 结算式长期 Agent 架构 | 设计文:借会计「凭证—账—表、结账前对账、期初结转」做有界、可信、可审计的长期上下文 |
关联:想用现成框架而不再手搓循环,看本站 pi-agent 系列(它把这套循环做成了库);想理解「工具=协议化能力」→ MCP。
动手
- 配好三个 env(§2);2. 建目录存好 runtime.mjs;3. 跑上面的连通性自测看到
pong;4. 进下一篇。
自测
- Agent 的 while 循环里,什么条件代表「该结束了」?
- 谁来决定工具能干什么?模型能不能绕过去?
- 为什么必须把
tool_result回填给模型? - 推理模型的回复里除了
text还常有什么块?取正文要过滤什么? - key 应该放哪?为什么不能进浏览器/进代码库?