🚀🚀🚀从零搭建一个 JS Agent:基于 DeepSeek API 的前端全栈实战指南
写给主前端、具备全栈能力的你。读完这篇,你将拥有一个真正能"思考-调用工具-再思考"的 Agent,而不是只会 chat 的套壳。
0. 写在前面:用前端的语言理解 Agent
如果你写过 React,那理解 Agent 几乎是零成本的:
| 前端概念 | Agent 对应概念 |
|---|---|
useState 维护组件状态 | messages[] 维护对话历史 |
useEffect 副作用 | 调用 Tools(查天气、读文件、搜数据库) |
| 事件循环 Event Loop | Agent 主循环 ReAct Loop |
| 组件渲染函数 | 一次 LLM 推理调用 |
| props 透传 | system prompt + context |
| 受控/非受控组件 | function calling 是受控、纯 prompt 解析是非受控 |
一句话定义 Agent:一个会循环调用大模型、根据模型输出决定下一步动作(回答用户 or 调用工具)的程序。
本质就是一个 while 循环加上一个 switch。别被神化它。
1. 环境准备(5 分钟搞定)
1.1 必需清单
- Node.js >= 18(要用原生
fetch,避免装 axios) - pnpm 或 npm
- DeepSeek API Key:去 DeepSeek 开放平台 注册,新用户有免费额度
- 一个趁手的编辑器(VSCode / Cursor)
1.2 初始化项目
mkdir js-agent-demo && cd js-agent-demo
pnpm init
pnpm add dotenv
pnpm add -D typescript tsx @types/node
npx tsc --init修改 package.json:
{
"type": "module",
"scripts": {
"dev": "tsx src/index.ts"
}
}修改 tsconfig.json 关键字段:
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "Bundler",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true
}
}1.3 配置环境变量
根目录新建 .env:
DEEPSEEK_API_KEY=sk-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
DEEPSEEK_BASE_URL=https://api.deepseek.com踩坑提示:
- 千万别把
.env提交到 git,加一行.gitignore - Node 18+ 才有原生
fetch,老版本需要node-fetch - 用
"type": "module"避免一会儿写import报错
2. 第一步:把"调通 DeepSeek API"做成最小可用单元
2.1 原理类比
DeepSeek 的接口与 OpenAI 完全兼容。你可以把它当成一个纯函数:
input: messages: Array<{role, content}>
output: { choices: [{ message: {role, content} }] }像极了 React 的 props => JSX。每次调用是无状态的,所以"对话上下文"需要你自己在外面维护——这点和 React 的 useState 完全同构。
2.2 最小 Chat 客户端
新建 src/llm.ts:
import 'dotenv/config';
const BASE_URL = process.env.DEEPSEEK_BASE_URL!;
const API_KEY = process.env.DEEPSEEK_API_KEY!;
export interface ChatMessage {
role: 'system' | 'user' | 'assistant' | 'tool';
content: string | null;
tool_calls?: ToolCall[];
tool_call_id?: string;
name?: string;
}
export interface ToolCall {
id: string;
type: 'function';
function: { name: string; arguments: string };
}
export interface ChatOptions {
messages: ChatMessage[];
tools?: any[];
tool_choice?: 'auto' | 'none' | 'required';
temperature?: number;
}
export async function chat(opts: ChatOptions) {
const res = await fetch(`${BASE_URL}/chat/completions`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${API_KEY}`,
},
body: JSON.stringify({
model: 'deepseek-chat',
temperature: opts.temperature ?? 0.3,
messages: opts.messages,
tools: opts.tools,
tool_choice: opts.tool_choice ?? 'auto',
}),
});
if (!res.ok) {
const text = await res.text();
throw new Error(`DeepSeek API ${res.status}: ${text}`);
}
return res.json();
}2.3 跑通第一个 Hello
新建 src/index.ts:
import { chat } from './llm.js';
const resp = await chat({
messages: [
{ role: 'system', content: '你是一个简洁的助手。' },
{ role: 'user', content: '一句话介绍 JS Agent 是什么。' },
],
});
console.log(resp.choices[0].message.content);运行:
pnpm dev看到输出说明链路通了。
踩坑提示:
- 401 → API Key 错了或没
Bearer前缀 - 402 → 账户余额不足,DeepSeek 不送钱就不工作
- 跨网络环境如果走代理,要在 fetch 里配
dispatcher,或者用undici设全局代理 model字段只能用deepseek-chat或deepseek-reasoner,别写成gpt-4
3. 第二步:从 Chat 升级到 Agent —— 引入 Function Calling
3.1 原理类比:从"会聊天"到"会做事"
纯 Chat 模型像一个只读组件——只能输出文本。Agent 的关键升级是给它"手",让它能调用你提供的函数。
Function Calling 的本质:
- 你告诉模型"这里有 N 个工具,每个工具签名是这样的"(相当于注册 props)
- 模型在回复时不直接说话,而是吐出一段 JSON:
{name: 'getWeather', arguments: '{"city":"北京"}'} - 你的代码解析这段 JSON,真正去执行函数
- 把函数结果塞回 messages 再请求模型一次
- 模型基于工具结果给出最终回答
是不是很像 React 受控表单的 onChange → setState → 重新渲染?
3.2 定义工具:天气 + 计算器 + 时间
新建 src/tools.ts:
export interface ToolDef {
schema: any; // 给 LLM 看的 JSON Schema
handler: (args: any) => Promise<string> | string;
}
export const tools: Record<string, ToolDef> = {
get_current_time: {
schema: {
type: 'function',
function: {
name: 'get_current_time',
description: '获取当前服务器时间',
parameters: { type: 'object', properties: {}, required: [] },
},
},
handler: () => new Date().toLocaleString('zh-CN', { hour12: false }),
},
calculator: {
schema: {
type: 'function',
function: {
name: 'calculator',
description: '执行一个数学表达式并返回结果。仅支持 + - * / ( ) 和数字。',
parameters: {
type: 'object',
properties: {
expression: { type: 'string', description: '例如: (1+2)*3' },
},
required: ['expression'],
},
},
},
handler: ({ expression }: { expression: string }) => {
if (!/^[\d+\-*/().\s]+$/.test(expression)) {
return 'ERROR: 表达式含非法字符';
}
// eslint-disable-next-line no-new-func
const result = Function(`"use strict"; return (${expression})`)();
return String(result);
},
},
get_weather: {
schema: {
type: 'function',
function: {
name: 'get_weather',
description: '查询某城市当前天气',
parameters: {
type: 'object',
properties: {
city: { type: 'string', description: '城市名,如 Beijing' },
},
required: ['city'],
},
},
},
handler: async ({ city }: { city: string }) => {
const r = await fetch(`https://wttr.in/${encodeURIComponent(city)}?format=j1`);
if (!r.ok) return `查询失败: ${r.status}`;
const data: any = await r.json();
const cur = data.current_condition?.[0];
return JSON.stringify({
city,
tempC: cur?.temp_C,
feelsLikeC: cur?.FeelsLikeC,
desc: cur?.weatherDesc?.[0]?.value,
humidity: cur?.humidity,
});
},
},
};
export const toolSchemas = Object.values(tools).map((t) => t.schema);踩坑提示:
- 工具描述(description)必须写清楚什么时候用、参数含义、返回什么,写得越清楚模型选得越准——这就是给模型看的 PRD
calculator这种用了Function构造器的,必须做白名单校验,否则就是 RCE 漏洞- 返回值最好是
string(JSON.stringify 一下),模型对字符串处理更稳定
4. 第三步:实现 ReAct 循环 —— Agent 的"心脏"
4.1 原理类比
ReAct = Reasoning + Acting,可以理解为 Event Loop:
while (true) {
const resp = await llm(messages); // 推理一步
if (resp 有 tool_calls) {
for (call of tool_calls) {
const result = await runTool(call); // 执行副作用
messages.push(tool 结果);
}
continue; // 把结果给模型,再来一轮
}
return resp.content; // 模型不再调工具 → 终止
}像不像浏览器 Event Loop?每一轮 tick 模型决定是"再调一次工具"还是"出最终答案"。
4.2 完整 Agent 实现
新建 src/agent.ts:
import { chat, ChatMessage } from './llm.js';
import { tools, toolSchemas } from './tools.js';
const MAX_STEPS = 8; // 防止无限循环,必须设上限
export async function runAgent(userInput: string, systemPrompt?: string) {
const messages: ChatMessage[] = [
{
role: 'system',
content:
systemPrompt ??
'你是一个能调用工具的助手。优先使用工具获取真实数据,不要编造。完成任务后用简洁的中文回答用户。',
},
{ role: 'user', content: userInput },
];
for (let step = 1; step <= MAX_STEPS; step++) {
console.log(`\n=== Step ${step} ===`);
const resp = await chat({ messages, tools: toolSchemas });
const msg = resp.choices[0].message;
// 把模型本轮的回复完整 push 回去(包括它的 tool_calls)
messages.push(msg);
// 没有 tool_calls,说明模型给最终答案了
if (!msg.tool_calls || msg.tool_calls.length === 0) {
console.log(`Agent 完成,共 ${step} 步`);
return msg.content;
}
// 有 tool_calls,挨个执行
for (const call of msg.tool_calls) {
const name = call.function.name;
const argsRaw = call.function.arguments;
console.log(`→ 调用工具 ${name}(${argsRaw})`);
let result: string;
try {
const args = JSON.parse(argsRaw || '{}');
const tool = tools[name];
if (!tool) {
result = `ERROR: 工具 ${name} 不存在`;
} else {
result = String(await tool.handler(args));
}
} catch (e: any) {
result = `ERROR: ${e.message}`;
}
console.log(`← 结果: ${result.slice(0, 200)}`);
messages.push({
role: 'tool',
tool_call_id: call.id,
name,
content: result,
});
}
}
throw new Error(`Agent 超过最大步数 ${MAX_STEPS}`);
}4.3 跑起来
修改 src/index.ts:
import { runAgent } from './agent.js';
const question = process.argv.slice(2).join(' ') ||
'现在几点?顺便帮我算 (1234 + 5678) * 2,并查一下北京的天气。';
const answer = await runAgent(question);
console.log('\n========== 最终回答 ==========');
console.log(answer);运行:
pnpm dev你会看到模型分步骤调用了 3 个工具,然后汇总成一段自然语言答案。这就是一个最朴素但完整的 Agent。
踩坑提示:
MAX_STEPS必须设,否则模型可能陷入"调用→失败→再调用"的死循环烧 tokenmessages.push(msg)时必须把 assistant 的tool_calls字段也带回去,否则下一轮模型会报messages with role tool must follow assistant with tool_callstool_call_id必须和模型给的对得上,是配对凭证- 一轮可能返回多个 tool_calls(并行调用),别假设只有一个
5. 第四步:让体验更前端 —— 流式输出(SSE)
纯 await 一次性返回结果,用户要干等。前端最熟悉的解法:Server-Sent Events。DeepSeek 支持 stream: true。
5.1 流式 LLM 封装
在 src/llm.ts 增加:
export async function* chatStream(opts: ChatOptions) {
const res = await fetch(`${BASE_URL}/chat/completions`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${API_KEY}`,
},
body: JSON.stringify({
model: 'deepseek-chat',
stream: true,
temperature: opts.temperature ?? 0.3,
messages: opts.messages,
tools: opts.tools,
tool_choice: opts.tool_choice ?? 'auto',
}),
});
if (!res.ok || !res.body) {
throw new Error(`stream error ${res.status}`);
}
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
while (true) {
const { value, done } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
// SSE 协议:以 \n\n 分块,每块以 "data: " 开头
const parts = buffer.split('\n\n');
buffer = parts.pop() ?? '';
for (const part of parts) {
const line = part.trim();
if (!line.startsWith('data:')) continue;
const payload = line.slice(5).trim();
if (payload === '[DONE]') return;
try {
yield JSON.parse(payload);
} catch {}
}
}
}5.2 在最终回答阶段开流
在 agent.ts 里,当判定到"模型不再调工具"时,可以再发起一次 stream 请求重放最后一步,把 token 一个个 yield 给上层。简单做法是改造主循环把"是否流式"作为开关:
// 伪代码:在第 4.2 节循环里替换最终返回那段
if (!msg.tool_calls || msg.tool_calls.length === 0) {
// 已经拿到完整 content 了,直接逐字打印模拟流式
for (const ch of msg.content ?? '') {
process.stdout.write(ch);
await new Promise((r) => setTimeout(r, 10));
}
return msg.content;
}如果你要做真正的端到端 stream,需要在每一轮都用 chatStream,并增量拼接 tool_calls.function.arguments(DeepSeek 会分片下发 arguments JSON)。这部分逻辑略复杂,建议先用上面的"伪流式"过渡,再升级。
踩坑提示:
- 流式下
tool_calls是增量到达的:{index:0, function:{arguments:'{"ci'}}、{index:0, function:{arguments:'ty":"北京"}'}},要按index拼接 - 浏览器端用
EventSource不支持自定义 header,必须用fetch + ReadableStream - 别忘了处理
[DONE]标志
6. 第五步:套个 Web UI —— 用前端最熟悉的方式 Demo
后端用 Express 暴露一个 SSE 接口,前端写个最简 HTML 验证。
6.1 后端 server
pnpm add express cors
pnpm add -D @types/express @types/cors新建 src/server.ts:
import express from 'express';
import cors from 'cors';
import { runAgent } from './agent.js';
const app = express();
app.use(cors());
app.use(express.json());
app.use(express.static('public'));
app.post('/api/agent', async (req, res) => {
const { question } = req.body;
res.setHeader('Content-Type', 'text/event-stream');
res.setHeader('Cache-Control', 'no-cache');
res.setHeader('Connection', 'keep-alive');
const send = (event: string, data: any) =>
res.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`);
try {
// 简化:复用非流式 runAgent,把过程通过 console hook 转发到前端
const origLog = console.log;
console.log = (...args) => {
send('log', args.join(' '));
origLog(...args);
};
const answer = await runAgent(question);
send('final', answer);
} catch (e: any) {
send('error', e.message);
} finally {
res.end();
}
});
app.listen(3000, () => console.log('Agent server on http://localhost:3000'));注意:根据
<network_security>约束,仅作本地开发示意。如需生产部署,请走你公司的标准网关。
6.2 前端页面
新建 public/index.html:
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<title>JS Agent Demo</title>
<style>
body { font-family: -apple-system, sans-serif; max-width: 720px; margin: 40px auto; padding: 0 16px; }
textarea { width: 100%; height: 80px; }
button { padding: 8px 20px; margin-top: 8px; }
#log { background: #f5f5f5; padding: 12px; border-radius: 4px; white-space: pre-wrap; font-family: monospace; font-size: 12px; max-height: 300px; overflow: auto; }
#answer { background: #e8f5e9; padding: 12px; border-radius: 4px; margin-top: 12px; }
</style>
</head>
<body>
<h2>JS Agent (DeepSeek) Demo</h2>
<textarea id="q">现在几点?帮我算 (1234+5678)*2,并查北京天气</textarea>
<br/>
<button onclick="ask()">运行 Agent</button>
<h4>执行轨迹</h4>
<pre id="log"></pre>
<h4>最终回答</h4>
<div id="answer"></div>
<script>
async function ask() {
const q = document.getElementById('q').value;
document.getElementById('log').textContent = '';
document.getElementById('answer').textContent = '';
const res = await fetch('/api/agent', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ question: q }),
});
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buf = '';
while (true) {
const { value, done } = await reader.read();
if (done) break;
buf += decoder.decode(value, { stream: true });
const events = buf.split('\n\n');
buf = events.pop();
for (const ev of events) {
const lines = ev.split('\n');
const type = lines.find(l => l.startsWith('event:'))?.slice(6).trim();
const data = JSON.parse(lines.find(l => l.startsWith('data:'))?.slice(5).trim());
if (type === 'log') document.getElementById('log').textContent += data + '\n';
if (type === 'final') document.getElementById('answer').textContent = data;
if (type === 'error') document.getElementById('answer').textContent = '❌ ' + data;
}
}
}
</script>
</body>
</html>启动:
npx tsx src/server.ts浏览器打开 http://localhost:3000,点击"运行 Agent",你能实时看到:
=== Step 1 ===
→ 调用工具 get_current_time({})
← 结果: 2026/5/23 23:40:00
=== Step 2 ===
→ 调用工具 calculator({"expression":"(1234+5678)*2"})
← 结果: 13824
=== Step 3 ===
→ 调用工具 get_weather({"city":"Beijing"})
← 结果: {"city":"Beijing","tempC":"22",...}
=== Step 4 ===
Agent 完成,共 4 步最终回答会是模型用自然语言整合三个结果的一段话。到这里你的 Agent 已经能用了。
7. 第六步:进阶能力清单(给你的下一步路线图)
实战中真正能用的 Agent 还需要这些,按优先级排:
| 能力 | 做法 | 难度 |
|---|---|---|
| 多轮对话记忆 | 把 messages 持久化到 sqlite/redis,按 sessionId 拉取 | ★ |
| Token 控制 | 用 tiktoken 估算长度,超过阈值就摘要旧消息 | ★★ |
| 真流式 tool_calls | 按 delta.tool_calls[i].function.arguments 拼接 JSON | ★★ |
| MCP 协议接入 | 用 @modelcontextprotocol/sdk 接外部工具市场 | ★★★ |
| 多 Agent 协作 | 一个 Planner 拆任务、多个 Worker 并行 | ★★★ |
| 错误自恢复 | 工具失败时把错误信息塞回去让模型 retry | ★★ |
| 可观测性 | 接入 Langfuse 或自建 trace 表 | ★★ |
| 安全沙箱 | 把 calculator 这类执行能力放到 Web Worker 或独立进程 | ★★★ |
8. 完整 Demo 效果验证
最后给你一份最终验收脚本,覆盖三种典型场景,确保你跟着做出来的 Agent 真的能跑通:
# 场景 1:只需工具
pnpm dev "现在几点?"
# 场景 2:多工具串行
pnpm dev "查北京天气,如果温度高于 20 度,就算一下 99*99"
# 场景 3:无工具直接回答
pnpm dev "用一句话解释什么是闭包"预期:
- 场景 1:1 步完成,调
get_current_time - 场景 2:2-3 步完成,先查天气,再判断条件调 calculator
- 场景 3:0 步工具调用,直接给文本答案
如果三个场景都符合预期,恭喜,你已经掌握了 JS Agent 的核心骨架。剩下的所有进阶——RAG、多 Agent、工作流编排——都只是在这个 while + switch 上加装饰。
9. 常见踩坑速查表
| 现象 | 原因 | 解法 |
|---|---|---|
| 模型不调用工具 | description 太模糊 / temperature 太高 | 改清楚 description,temperature 设 0.1-0.3 |
tool_call_id 报错 | assistant 消息没把 tool_calls 透传回去 | 直接 messages.push(msg) 整个对象 |
| arguments JSON 解析失败 | 流式模式没拼完整就 parse | 等同 index 的所有片段到齐再 parse |
| 死循环 | 工具返回空 / 报错没说清原因 | 工具失败时返回明确错误描述 |
| 中文乱码 | response 默认 latin1 | TextDecoder('utf-8') 显式声明 |
| 余额报 402 | DeepSeek 没钱了 | 控制台充值 |
写在最后:Agent 的工程难点从来不是"调通 LLM",而是把"工具集设计 + 上下文管理 + 失败回退 + 可观测性"这四件事做扎实。今天这份 Demo 是地基,后面盖几层楼,就看你的业务想做多大了。