Skip to content

🚀🚀🚀从零搭建一个 JS Agent:基于 DeepSeek API 的前端全栈实战指南

写给主前端、具备全栈能力的你。读完这篇,你将拥有一个真正能"思考-调用工具-再思考"的 Agent,而不是只会 chat 的套壳。

0. 写在前面:用前端的语言理解 Agent

如果你写过 React,那理解 Agent 几乎是零成本的:

前端概念Agent 对应概念
useState 维护组件状态messages[] 维护对话历史
useEffect 副作用调用 Tools(查天气、读文件、搜数据库)
事件循环 Event LoopAgent 主循环 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 初始化项目

bash
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

json
{
  "type": "module",
  "scripts": {
    "dev": "tsx src/index.ts"
  }
}

修改 tsconfig.json 关键字段:

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

typescript
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

typescript
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);

运行:

bash
pnpm dev

看到输出说明链路通了。

踩坑提示

  • 401 → API Key 错了或没 Bearer 前缀
  • 402 → 账户余额不足,DeepSeek 不送钱就不工作
  • 跨网络环境如果走代理,要在 fetch 里配 dispatcher,或者用 undici 设全局代理
  • model 字段只能用 deepseek-chatdeepseek-reasoner,别写成 gpt-4

3. 第二步:从 Chat 升级到 Agent —— 引入 Function Calling

3.1 原理类比:从"会聊天"到"会做事"

纯 Chat 模型像一个只读组件——只能输出文本。Agent 的关键升级是给它"手",让它能调用你提供的函数。

Function Calling 的本质:

  1. 你告诉模型"这里有 N 个工具,每个工具签名是这样的"(相当于注册 props)
  2. 模型在回复时不直接说话,而是吐出一段 JSON:{name: 'getWeather', arguments: '{"city":"北京"}'}
  3. 你的代码解析这段 JSON,真正去执行函数
  4. 把函数结果塞回 messages 再请求模型一次
  5. 模型基于工具结果给出最终回答

是不是很像 React 受控表单的 onChangesetState → 重新渲染?

3.2 定义工具:天气 + 计算器 + 时间

新建 src/tools.ts

typescript
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

typescript
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

typescript
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);

运行:

bash
pnpm dev

你会看到模型分步骤调用了 3 个工具,然后汇总成一段自然语言答案。这就是一个最朴素但完整的 Agent。

踩坑提示

  • MAX_STEPS 必须设,否则模型可能陷入"调用→失败→再调用"的死循环烧 token
  • messages.push(msg) 时必须把 assistant 的 tool_calls 字段也带回去,否则下一轮模型会报 messages with role tool must follow assistant with tool_calls
  • tool_call_id 必须和模型给的对得上,是配对凭证
  • 一轮可能返回多个 tool_calls(并行调用),别假设只有一个

5. 第四步:让体验更前端 —— 流式输出(SSE)

纯 await 一次性返回结果,用户要干等。前端最熟悉的解法:Server-Sent Events。DeepSeek 支持 stream: true

5.1 流式 LLM 封装

src/llm.ts 增加:

typescript
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 给上层。简单做法是改造主循环把"是否流式"作为开关:

typescript
// 伪代码:在第 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

bash
pnpm add express cors
pnpm add -D @types/express @types/cors

新建 src/server.ts

typescript
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

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>

启动:

bash
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_callsdelta.tool_calls[i].function.arguments 拼接 JSON★★
MCP 协议接入@modelcontextprotocol/sdk 接外部工具市场★★★
多 Agent 协作一个 Planner 拆任务、多个 Worker 并行★★★
错误自恢复工具失败时把错误信息塞回去让模型 retry★★
可观测性接入 Langfuse 或自建 trace 表★★
安全沙箱calculator 这类执行能力放到 Web Worker 或独立进程★★★

8. 完整 Demo 效果验证

最后给你一份最终验收脚本,覆盖三种典型场景,确保你跟着做出来的 Agent 真的能跑通:

bash
# 场景 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 默认 latin1TextDecoder('utf-8') 显式声明
余额报 402DeepSeek 没钱了控制台充值

写在最后:Agent 的工程难点从来不是"调通 LLM",而是把"工具集设计 + 上下文管理 + 失败回退 + 可观测性"这四件事做扎实。今天这份 Demo 是地基,后面盖几层楼,就看你的业务想做多大了。