不用 LangChain,200 行 TypeScript 手搓一个能联网、能写文件的 Agent

4 阅读9分钟

不用 LangChain,200 行 TypeScript 手搓一个能联网、能写文件的 Agent

提到 Agent 开发,很多人第一反应是 LangChain、LlamaIndex、AutoGen 这些框架。框架固然强大,但黑盒也多:prompt 被层层包装、调用链路深不见底,出了问题只能靠玄学调试。

最近也是开始学习关于agent相关的知识,在某小破站上看到的一位up,跟着手敲一遍整体流程,也是成功实现一个简单的客户端agent,整体基于node实现,没有使用第三方框架,对于自己去理解框架的实现底层还是有很大的帮助

这篇文章带你不依赖任何 Agent 框架,只用 OpenAI SDK + MCP 官方 SDK,手搓一个最小可用的 Agent。它能听懂你的指令,自己决定调用"网页抓取"还是"文件读写"工具,循环执行直到任务完成。核心代码不到 200 行,看完你会对 Agent 的本质有完全不一样的理解。

一、先想清楚:Agent 到底是什么?

抛开框架的糖衣,Agent 的本质就一句话:

LLM(大脑) + Tools(手脚) + Loop(循环)

工作流是一个经典的 ReAct 循环:

用户提问
   ↓
LLM 思考 → 直接回答 → 结束
   ↓
LLM 思考 → 输出 tool_calls(我要调用 xx 工具,参数是 yy)
   ↓
程序解析 tool_calls → 执行真正的工具 → 把结果塞回对话
   ↓
带着工具结果再问 LLM → 回到第二步,直到 LLM 不再需要工具

整个流程里没有魔法,唯一的"智能"就是 LLM 自己决定何时调工具、调哪个、传什么参数。我们要做的只是把这个循环用代码翻译出来。

本文的技术选型:

  • 大模型:DeepSeek(兼容 OpenAI 协议,便宜好用)
  • 工具生态:MCP(Model Context Protocol),Anthropic 推出的工具协议,相当于"AI 界的 USB 接口"
  • 语言:TypeScript + tsx 直接运行

二、项目初始化

mkdir ts-node-esm-template && cd ts-node-esm-template
pnpm init
pnpm add openai dotenv chalk @modelcontextprotocol/sdk
pnpm add -D typescript tsx @types/node

package.json 关键配置:

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

新建 .env 文件存放密钥(千万别提交到 Git):

OPENAI_API_KEY=sk-xxxxxxxxxxxxxxxxxxxxxxxx
OPENAI_BASE_URL=https://api.deepseek.com

目录结构非常简单:

src/
├── index.ts       # 入口,组装 Agent
├── Agent.ts       # Agent 核心:ReAct 循环
├── ChatOpenAi.ts  # LLM 封装:对话、流式解析、上下文管理
├── MCPClient.ts   # MCP 封装:连接工具服务器、调用工具
└── utils.ts       # 日志美化

三、第一步:封装 MCPClient —— 给 Agent 接上"手脚"

MCP 协议下,工具由独立的 Server 进程提供,通过 stdio 与我们的 Client 通信。比如:

  • mcp-fetch-server:提供网页抓取能力
  • @modelcontextprotocol/server-filesystem:提供文件读写能力

我们需要一个类,负责拉起 Server 进程、握手、拿到工具列表、转发工具调用:

// src/MCPClient.ts
import { Client } from "@modelcontextprotocol/sdk/client";
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio";
import { Tool } from "@modelcontextprotocol/sdk/types";
import { logTitle } from "./utils";

export default class MCPClient {
  private mcp: Client;
  private transport: StdioClientTransport | null = null;
  private tools: Tool[] = [];
  private command: string = "";
  private args: string[] = [];

  constructor(name: string, command: string, args: string[], version?: string) {
    this.mcp = new Client({ name, version: version || "1.0.0" });
    this.command = command
    this.args = args
  }

  async connectToServer() {
    try {
      // 通过 stdio 拉起 MCP Server 子进程
      this.transport = new StdioClientTransport({
        command: this.command,
        args: this.args,
      });
      await this.mcp.connect(this.transport);

      // 握手成功后,问 Server:你都有哪些工具?
      const toolsResult = await this.mcp.listTools();
      this.tools = toolsResult.tools.map((tool) => {
        return {
          name: tool.name,
          description: tool.description,
          inputSchema: tool.inputSchema,
        };
      });
      logTitle("Connected to server with tools(成功连接MCP服务器)")
      console.log(this.tools.map(({ name }) => name))
    } catch (e) {
      console.log("Failed to connect to MCP server: ", e);
      throw e;
    }
  }

  // 真正执行一次工具调用
  async callTool(name: string, params: Record<string, any>) {
    return await this.mcp.callTool({ name, arguments: params })
  }

  public async close() {
    await this.mcp?.close()
  }
  public async init() {
    await this.connectToServer()
  }
  public getTools() {
    return this.tools
  }
}

代码很直白:StdioClientTransport 负责把 npx -y mcp-fetch-server 拉成子进程,listTools() 拿到工具元信息(名称、描述、JSON Schema 参数定义),callTool() 转发调用。

先单独测一下它能不能工作:

const fetchMcp = new MCPClient("fetch", "npx", ["-y", "mcp-fetch-server"]);
await fetchMcp.init()
console.log(fetchMcp.getTools())
await fetchMcp.close()

输出:

[ 'fetch_html', 'fetch_markdown', 'fetch_txt', 'fetch_json', 'fetch_readable', 'fetch_youtube_transcript' ]

6 个工具到手。这里有个小细节:getTools() 必须是同步方法,因为工具列表在 init() 时就缓存好了。我一开始写成了 async,导致后面 .find() 作用在 Promise 上,TypeScript 直接报错"Promise 上不存在属性 find"——这是手搓过程中的第一个坑。

四、第二步:封装 ChatOpenAi —— 装好"大脑"

这个类是全文最核心的部分,它要干四件事:

  1. 管理对话历史 messages(多轮记忆)
  2. 把 MCP 工具格式转换成 OpenAI 的 tools 格式
  3. 发起流式请求,边生成边打印(打字机效果)
  4. 从流式数据里拼出完整的 tool_calls

4.1 工具格式转换

MCP 的工具描述和 OpenAI 的 function calling 格式略有差异,做个简单映射:

private getToolsDefinition() {
  return this.tools.map((tool) => ({
    type: "function" as const,
    function: {
      name: tool.name,
      description: tool.description,
      parameters: tool.inputSchema,  // 参数 JSON Schema
    },
  }))
}

LLM 就是靠 description 和 parameters 来理解"这个工具是干嘛的、需要什么参数",所以 MCP Server 的工具描述写得越好,模型调用得越准。

4.2 对话与上下文管理

构造函数里处理 system prompt 和初始上下文,对话历史用一个数组维护:

// src/ChatOpenAi.ts
import OpenAI from 'openai'
import 'dotenv/config'
import { Tool } from '@modelcontextprotocol/sdk/types'
import { logTitle } from './utils'

export interface ToolCall {
  id: string,
  function: {
    name: string,
    arguments: string
  },
}

export default class ChatOpenAi {
  private llm: OpenAI;
  private model: string;
  private messages: OpenAI.Chat.ChatCompletionMessageParam[] = [];
  private tools: Tool[];

  constructor(model: string, systemPrompt: string = "", tools: any[] = [], context: string = "") {
    this.llm = new OpenAI({
      apiKey: process.env.OPENAI_API_KEY,     // 从 .env 读取,不写死
      baseURL: process.env.OPENAI_BASE_URL,   // 指向 DeepSeek 等兼容端点
    })
    this.model = model
    this.tools = tools
    if (systemPrompt) {
      this.messages.push({ role: "system", content: systemPrompt })
    }
    if (context) {
      this.messages.push({ role: "user", content: context })
    }
  }
}

4.3 全文最难的部分:流式 tool_calls 拼接

非流式请求里,tool_calls 是一次性完整返回的。但流式模式下,tool_call 是被切碎了分批吐出来的:

  • id 和 function.name:只在第一个 chunk 出现一次
  • function.arguments:像正文一样,一个字一个字地流回来

每个 chunk 带一个 index 标记属于第几个工具调用。拼接逻辑如下:

async chat(message: string) {
  if (message) {
    logTitle(message)
    this.messages.push({ role: "user", content: message })
  }
  const stream = await this.llm.chat.completions.create({
    model: this.model,
    messages: this.messages,
    tools: this.getToolsDefinition(),
    stream: true,
  })
  let content = ""
  let toolCalls: ToolCall[] = []

  for await (const chunk of stream) {
    const delta = chunk.choices[0].delta || ""
    // 1. 正文:累加并实时打印
    if (delta.content) {
      const contentChunk = delta.content || ""
      content += contentChunk
      process.stdout.write(contentChunk)
    }
    // 2. tool_calls:按 index 分桶累加
    if (delta.tool_calls) {
      for (const toolCallChunk of delta.tool_calls) {
        // 第一次出现的 tool_call,先占位
        if (toolCalls.length <= toolCallChunk.index) {
          toolCalls.push({
            id: "",
            function: { name: "", arguments: "" },
          })
        }
        const currentToolCall = toolCalls[toolCallChunk.index]
        if (toolCallChunk.id) currentToolCall.id += toolCallChunk.id
        if (toolCallChunk.function?.name) currentToolCall.function.name += toolCallChunk.function.name
        if (toolCallChunk.function?.arguments) currentToolCall.function.arguments += toolCallChunk.function.arguments
      }
    }
  }

  // 把 assistant 的回复(含 tool_calls)压入历史
  this.messages.push({
    role: "assistant",
    content,
    tool_calls: toolCalls.map((toolCall) => ({
      type: "function" as const,
      id: toolCall.id,
      function: toolCall.function,
    })),
  })
  return { content, toolCalls }
}

⚠️ 重点避坑:拼接条件判断的是 chunk 里有没有值,而不是累加结果有没有值!我最开始写成了 if (currentToolCall.id) ...,初始值是空字符串(falsy),永远进不去,结果所有 tool_call 的 id 都是 ""。后续把工具结果回传时,API 直接报错:

BadRequestError: 400 Duplicate value for 'tool_call_id' of  in message[1]

因为三条 tool 消息共享同一个空 id,被判重。这个 bug 隐蔽性极强,流式场景下尤其要注意。

4.4 回传工具结果

工具执行完后,结果要以 role: "tool" 的消息压入历史,并用 tool_call_id 和之前的调用一一对应:

public appendToolMessage(toolCallId: string, toolOutput: string) {
  this.messages.push({
    role: "tool",
    content: toolOutput,
    tool_call_id: toolCallId,
  })
}
appendToolResult(toolCallId: string, toolOutput: string) {
  this.appendToolMessage(toolCallId, toolOutput)
}

这就是为什么上面 id 拼接错了会直接炸——LLM 靠 tool_call_id 把"调用"和"结果"配对。

五、第三步:组装 Agent —— 让循环转起来

万事俱备,只差把大脑和手脚缝在一起。Agent 类就两个核心方法:

  • init():初始化所有 MCP Client,汇总工具列表,创建 LLM 实例
  • invoke():ReAct 循环主体
// src/Agent.ts
import ChatOpenAi from "./ChatOpenAi";
import MCPClient from "./MCPClient";
import { logTitle } from "./utils";

export default class Agent {
  private llm: ChatOpenAi | null = null;
  private mcpClients: MCPClient[];
  private model: string;
  private systemPrompt: string;
  private context: string;

  constructor(model: string, mcpClients: MCPClient[], systemPrompt: string = "", context: string = "") {
    this.model = model
    this.systemPrompt = systemPrompt
    this.context = context
    this.mcpClients = mcpClients
  }

  public async init() {
    logTitle("初始化LLM AND TOOLS")
    // 1. 拉起所有 MCP Server
    for (const mcp of this.mcpClients) {
      await mcp.init()
    }
    // 2. 汇总所有工具,交给 LLM
    const tools = this.mcpClients.flatMap((mcp) => mcp.getTools())
    this.llm = new ChatOpenAi(this.model, this.systemPrompt, tools, this.context)
  }

  public async close() {
    logTitle("关闭LLM AND TOOLS")
    for (const mcp of this.mcpClients) {
      await mcp.close()
    }
  }

  public async invoke(prompt: string) {
    if (!this.llm) throw new Error("LLM NOT INITIALIZED");
    let response = await this.llm.chat(prompt);

    // ★ ReAct 循环:只要 LLM 还想调工具,就一直转
    while (response.toolCalls?.length) {
      for (const toolCall of response.toolCalls) {
        // 1. 找到这个工具属于哪个 MCP Server
        const mcp = this.mcpClients.find((mcpClient) =>
          mcpClient.getTools().find((t: any) => t.name === toolCall.function.name)
        );
        if (mcp) {
          logTitle(`调用工具${toolCall.function.name}`)
          console.log(`工具入参:${toolCall.function.arguments}`)
          // 2. 真正执行工具
          const toolOutput = await mcp.callTool(
            toolCall.function.name,
            JSON.parse(toolCall.function.arguments)
          );
          // 3. 把结果回传给 LLM
          this.llm.appendToolResult(toolCall.id, JSON.stringify(toolOutput))
        } else {
          this.llm.appendToolResult(toolCall.id, "工具不存在")
        }
      }
      // 4. 带着工具结果,再次询问 LLM
      response = await this.llm.chat('')
    }
    await this.close()
    return response.content
  }
}

循环的退出条件非常自然:LLM 不再返回 tool_calls,说明它认为信息足够、可以直接回答了。

六、点火发射:爬取天气网站并生成报告

组装入口,给 Agent 配上"联网"和"文件系统"两组工具:

// src/index.ts
import MCPClient from "./MCPClient";
import Agent from "./Agent";

const currentDir = process.cwd()
// 联网抓取工具组
const fetchMCP = new MCPClient("fetch", "npx", ["-y", "mcp-fetch-server"]);
// 文件读写工具组(限制在当前目录)
const fileMCP = new MCPClient("filesystem", "npx", [
  "-y",
  "@modelcontextprotocol/server-filesystem",
  currentDir
]);

async function main() {
  const agent = new Agent('deepseek-flash', [fetchMCP, fileMCP])
  await agent.init()
  const response = await agent.invoke(
    "爬取https://sh.weather.com.cn/ 并解析天气信息,总结后保存到当前目录下的weather.md文件中"
  )
  console.log(response)
}
main()

运行 pnpm dev,见证奇迹的时刻:

============================== 初始化LLM AND TOOLS ==============================
[ 'fetch_html', 'fetch_markdown', 'fetch_txt', ... ]
[ 'read_file', 'write_file', 'edit_file', ... ]

======= 爬取https://sh.weather.com.cn/ 并解析天气信息... =======
我先获取该天气网站的信息。
========================= 调用工具fetch_readable =========================
========================= 调用工具fetch_markdown =========================
首页天气数据由 JavaScript 动态加载,我需要找到它的数据接口。
============================ 调用工具fetch_txt ===========================
...(Agent 连续试了 7 个数据接口,自己找到了真实数据源)
==================== 调用工具list_allowed_directories ====================
数据已获取,现在生成并保存总结文档。
============================ 调用工具write_file ==========================
============================ 调用工具edit_file ===========================(自己还校对了一遍)
已完成爬取、解析并保存到 weather.md

整个过程中最让我惊艳的是:首页是 JS 动态渲染的,直接抓 HTML 拿不到数据。Agent 发现这一点后,自己分析了页面结构,转而尝试中国天气网的底层数据接口,连续试了五六个 URL,最终成功拿到实况数据、7 天预报和生活指数,写了一份结构完整的天气报告——全程零人工干预。

七、手搓路上踩过的坑(血泪总结)

  1. uvx 不是内部命令:很多 MCP 教程用 Python 生态的 uvx mcp_server_fetch,但 Windows 上没装 uv 会直接报错。Node 生态有平替(如 mcp-fetch-server),npx -y 即可运行。
  2. getTools() 误写成 async:工具列表在 init 时已经缓存,同步返回即可。写成 Promise 后,后面 .flatMap() / .find() 全部拿到的是 Promise 对象,工具列表实际为空。
  3. 流式 tool_call 拼接条件写反:if (currentToolCall.id) 判断的是累加结果(初始为 falsy),导致 id 永远拼不上,最终 API 报 Duplicate value for 'tool_call_id'。正确姿势是判断 chunk:if (toolCallChunk.id)。
  4. 密钥安全:API Key 一律放 .env,通过 dotenv/config 注入 process.env,代码里绝不出现明文 key,并且 .env 要加进 .gitignore。

八、总结

回顾一下我们做了什么:

模块职责代码量
MCPClient拉起工具服务器、枚举工具、转发调用~50 行
ChatOpenAi对话历史管理、流式解析、tool_calls 拼接~100 行
AgentReAct 循环:LLM 决策 → 执行工具 → 结果回填~60 行

加起来不到 200 行,就实现了一个能自主规划、多轮调用工具的 Agent。框架做的事情本质上也是这些,只是包装了更多 prompt 工程、重试、可观测性等能力。

当你亲手写过一遍这个循环,再去看 LangChain 的 AgentExecutor、OpenAI 的 Responses API,会有种"原来如此"的通透感。先理解本质,再使用框架,这大概是手搓的最大意义。