不用 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 直接运行
二、项目初始化
bash
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 关键配置:
json
{
"type": "module",
"scripts": {
"dev": "tsx src/index.ts"
}
}
新建 .env 文件存放密钥(千万别提交到 Git):
bash
OPENAI_API_KEY=sk-xxxxxxxxxxxxxxxxxxxxxxxx
OPENAI_BASE_URL=https://api.deepseek.com
目录结构非常简单:
bash
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 进程、握手、拿到工具列表、转发工具调用:
typescript
// 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() 转发调用。
先单独测一下它能不能工作:
typescript
const fetchMcp = new MCPClient("fetch", "npx", ["-y", "mcp-fetch-server"]);
await fetchMcp.init()
console.log(fetchMcp.getTools())
await fetchMcp.close()
输出:
css
[ 'fetch_html', 'fetch_markdown', 'fetch_txt', 'fetch_json', 'fetch_readable', 'fetch_youtube_transcript' ]
6 个工具到手。这里有个小细节:getTools() 必须是同步 方法,因为工具列表在 init() 时就缓存好了。我一开始写成了 async,导致后面 .find() 作用在 Promise 上,TypeScript 直接报错"Promise 上不存在属性 find"------这是手搓过程中的第一个坑。
四、第二步:封装 ChatOpenAi ------ 装好"大脑"
这个类是全文最核心的部分,它要干四件事:
- 管理对话历史
messages(多轮记忆) - 把 MCP 工具格式转换成 OpenAI 的
tools格式 - 发起流式请求,边生成边打印(打字机效果)
- 从流式数据里拼出完整的 tool_calls
4.1 工具格式转换
MCP 的工具描述和 OpenAI 的 function calling 格式略有差异,做个简单映射:
typescript
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 和初始上下文,对话历史用一个数组维护:
typescript
// 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 标记属于第几个工具调用。拼接逻辑如下:
typescript
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 直接报错:
vbnetBadRequestError: 400 Duplicate value for 'tool_call_id' of in message[1]因为三条 tool 消息共享同一个空 id,被判重。这个 bug 隐蔽性极强,流式场景下尤其要注意。
4.4 回传工具结果
工具执行完后,结果要以 role: "tool" 的消息压入历史,并用 tool_call_id 和之前的调用一一对应:
typescript
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 循环主体
typescript
// 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 配上"联网"和"文件系统"两组工具:
typescript
// 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,见证奇迹的时刻:
ini
============================== 初始化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 天预报和生活指数,写了一份结构完整的天气报告------全程零人工干预。
七、手搓路上踩过的坑(血泪总结)
uvx不是内部命令 :很多 MCP 教程用 Python 生态的uvx mcp_server_fetch,但 Windows 上没装uv会直接报错。Node 生态有平替(如mcp-fetch-server),npx -y即可运行。getTools()误写成 async :工具列表在 init 时已经缓存,同步返回即可。写成 Promise 后,后面.flatMap()/.find()全部拿到的是 Promise 对象,工具列表实际为空。- 流式 tool_call 拼接条件写反 :
if (currentToolCall.id)判断的是累加结果(初始为 falsy),导致 id 永远拼不上,最终 API 报Duplicate value for 'tool_call_id'。正确姿势是判断 chunk:if (toolCallChunk.id)。 - 密钥安全 :API Key 一律放
.env,通过dotenv/config注入process.env,代码里绝不出现明文 key,并且.env要加进.gitignore。
八、总结
回顾一下我们做了什么:
| 模块 | 职责 | 代码量 |
|---|---|---|
MCPClient |
拉起工具服务器、枚举工具、转发调用 | ~50 行 |
ChatOpenAi |
对话历史管理、流式解析、tool_calls 拼接 | ~100 行 |
Agent |
ReAct 循环:LLM 决策 → 执行工具 → 结果回填 | ~60 行 |
加起来不到 200 行,就实现了一个能自主规划、多轮调用工具的 Agent。框架做的事情本质上也是这些,只是包装了更多 prompt 工程、重试、可观测性等能力。
当你亲手写过一遍这个循环,再去看 LangChain 的 AgentExecutor、OpenAI 的 Responses API,会有种"原来如此"的通透感。先理解本质,再使用框架,这大概是手搓的最大意义。