不用框架,用 TypeScript 从零搭一个 Agent 框架

之前用 Python notebook 手写了 Agent 循环、ReAct、上下文管理,散在几个 ipynb 里,每次想复用都得复制粘贴。这次索性用 TypeScript 重新做了一个可复用的框架,顺便把 MCP 协议也接进来了。

项目结构

css 复制代码
mini-agent/
├── src/
│   ├── types.ts      ← 类型定义,复用 openai SDK 类型
│   ├── context.ts    ← 上下文管理:token 估算、截断、摘要裁剪
│   ├── tools.ts      ← 工具注册表:本地 + MCP 统一接口
│   ├── agent.ts      ← Agent 核心:ReAct 循环 + 事件系统
│   └── index.ts      ← 入口:REPL + 库导出
├── package.json
└── tsconfig.json

依赖就三个:openai(调 LLM)、dotenv(读环境变量)、@modelcontextprotocol/sdk(接 MCP Server)。

四个文件的关系:

arduino 复制代码
用户输入问题
     │
     ▼
 index.ts      ← 创建组件,启动 REPL
     │
     ▼
 agent.ts      ← while 循环,调 LLM,决定下一步
     │
     ├──► tools.ts     ← 管理和执行工具
     │
     └──► context.ts   ← 控制对话历史不爆 token

类型设计

types.ts 的原则:openai SDK 已经定义好的类型直接用,不重新造。

typescript 复制代码
import type OpenAI from "openai";

export type ChatMessage = OpenAI.ChatCompletionMessageParam;
export type AssistantMessage = OpenAI.ChatCompletionMessage;
export type ToolCall = OpenAI.ChatCompletionMessageToolCall;
export type ToolDefinition = OpenAI.ChatCompletionTool;

需要自己定义的主要是两块。一个是工具注册:

typescript 复制代码
export type ToolHandler = (
  args: Record<string, unknown>
) => Promise<string> | string;

export interface ToolRegistration {
  name: string;
  description: string;
  parameters: Record<string, unknown>;
  handler: ToolHandler;
  source: "local" | "mcp";  // 标记来源,调试时能分辨
}

另一个是事件系统,用联合类型定义了 Agent 运行时会产生的几种事件:

typescript 复制代码
export type AgentEvent =
  | { type: "thought"; content: string }
  | { type: "tool_call"; name: string; args: Record<string, unknown> }
  | { type: "tool_result"; name: string; result: string }
  | { type: "answer"; content: string }
  | { type: "error"; message: string }
  | { type: "context_trimmed"; before: number; after: number };

用联合类型而不是 enum,是因为每种事件携带的数据不一样。外部用 event.type 做类型收窄就行。


ToolRegistry:解决两份数据同步的问题

Python notebook 里的痛点:

python 复制代码
# 给执行用的
TOOL_FUNCTIONS = { "get_weather": get_weather, "calculate": calculate }

# 给 LLM 用的 JSON Schema
FC_TOOLS = [
    { "type": "function", "function": { "name": "get_weather", ... } },
    { "type": "function", "function": { "name": "calculate", ... } },
]

TS 版只需注册一次,两份数据自动维护:

typescript 复制代码
registry.register(
  "get_weather",
  "获取指定城市的当前天气",
  {
    type: "object",
    properties: { city: { type: "string", description: "城市名称" } },
    required: ["city"],
  },
  ({ city }) => {
    const data: Record<string, string> = {
      北京: "晴天,28°C,湿度 45%",
      上海: "多云,31°C,湿度 72%",
    };
    return data[city as string] ?? `暂无${city}的天气数据`;
  }
);

内部就是一个 Map<string, ToolRegistration>。发给 LLM 时调 getToolDefinitions() 自动转成 OpenAI 格式,执行时调 execute(name, args) 从 Map 里取 handler 跑。

MCP 工具怎么接进来

connectMCP() 的逻辑不复杂:启动 MCP Server 进程,问它"你有什么工具",拿到列表之后为每个工具包一层 handler,塞进同一个 Map。

typescript 复制代码
async connectMCP(command: string, args: string[]): Promise<number> {
  this.mcpClient = new Client({ name: "mini-agent", version: "1.0.0" });
  this.mcpTransport = new StdioClientTransport({ command, args });
  await this.mcpClient.connect(this.mcpTransport);

  const { tools } = await this.mcpClient.listTools();
  const client = this.mcpClient;

  for (const tool of tools) {
    const handler: ToolHandler = async (args) => {
      const result = await client.callTool({ name: tool.name, arguments: args });
      const content = result.content as Array<{ type: string; text?: string }>;
      return content
        .filter((c) => c.type === "text" && c.text)
        .map((c) => c.text!)
        .join("\n");
    };

    this.tools.set(tool.name, {
      name: tool.name,
      description: tool.description ?? "",
      parameters: tool.inputSchema as Record<string, unknown>,
      handler,
      source: "mcp",
    });
  }
  return tools.length;
}

MCP 工具的 handler 内部走的是 MCP Client → Server 的远程调用,但从 Agent 的角度看不到区别 --- 都是 execute(name, args) 返回一个 string。

有个问题现在没处理:本地和 MCP 工具同名时,后注册的直接覆盖前面的。当前演示场景下重名的工具功能一样所以没事,真要上线得加个前缀或者冲突检测。


ContextManager:从 Python 移植过来

之前 Python 版 ContextManager 的 TS 重写。逻辑一样:token 估算 → 工具结果截断 → 超预算时用 LLM 压缩旧消息。

Token 估算

和 Python 版同一套规则:

typescript 复制代码
export function estimateTokens(text: string): number {
  let chineseChars = 0;
  for (const char of text) {
    const code = char.codePointAt(0) ?? 0;
    if (code >= 0x4e00 && code <= 0x9fff) chineseChars++;
  }
  const otherChars = text.length - chineseChars;
  return Math.ceil(chineseChars * 1.5 + otherChars / 4);
}

中文 1 字约 1.5 token,英文 4 字符约 1 token,误差 ±20%。不精确,但拿来做预算判断够了。

Python 版踩过的坑,TS 版没有

Python 版的 ContextManager 被一个类型问题卡了挺久:response.choices[0].message 返回的是 Pydantic 对象不是 dict,后面遍历 messages 时用 msg["role"] 访问就炸了 --- TypeError: 'ChatCompletionMessage' object is not subscriptable。最后在所有入口加了 _msg_to_dict() 做兼容。

TS 版不需要这一步。SDK 返回的就是普通对象,msg.role 直接访问,编译时类型系统就保证了这些访问是合法的。

裁剪流程

typescript 复制代码
async prepare(messages: ChatMessage[]) {
  // 1. 截断过长的工具结果(超过 500 字的砍掉)
  const processed = messages.map((msg) => {
    if (msg.role === "tool" && typeof msg.content === "string") {
      return { ...msg, content: this.truncateToolResult(msg.content) };
    }
    return msg;
  });

  // 2. 检查 token 预算,没超就直接返回
  const beforeTokens = estimateMessagesTokens(processed);
  if (beforeTokens <= this.config.maxContextTokens) {
    return { messages: processed, trimmed: false, beforeTokens, afterTokens: beforeTokens };
  }

  // 3. 超了 → system prompt 保留,旧消息压缩成摘要,最近 N 条原样保留
  const systemMsgs = processed.filter((m) => m.role === "system");
  const nonSystem = processed.filter((m) => m.role !== "system");
  const recent = nonSystem.slice(-this.config.keepRecent);
  const old = nonSystem.slice(0, -this.config.keepRecent);

  if (old.length > 0) {
    this.summary = await this.generateSummary(old);
  }

  const result = [...systemMsgs];
  if (this.summary) {
    result.push({ role: "user", content: `[之前的对话摘要] ${this.summary}` });
  }
  result.push(...recent);

  return { messages: result, trimmed: true, beforeTokens, afterTokens: estimateMessagesTokens(result) };
}

返回值带了 trimmedbeforeTokensafterTokens,Agent 拿到之后可以通过事件系统告诉外部"上下文被裁了"。


Agent:ReAct 循环

agent.ts 对应 Python 版的 react_agent_fc() 函数。结构差不多,但有几个改动值得说。

主循环

typescript 复制代码
async chat(userMessage: string): Promise<string> {
  this.messages.push({ role: "user", content: userMessage });
  const toolDefs = this.toolRegistry.getToolDefinitions();

  for (let step = 0; step < this.maxSteps; step++) {
    // 上下文管理
    const { messages: apiMessages, trimmed } = await this.contextManager.prepare(this.messages);
    if (trimmed) this.emit({ type: "context_trimmed", ... });

    // 调 LLM
    const response = await this.client.chat.completions.create({
      model: this.model,
      messages: apiMessages,
      tools: toolDefs.length > 0 ? toolDefs : undefined,
      temperature: this.temperature,
    });
    const msg = response.choices[0]?.message;

    // 过滤出 function 类型的 tool_calls
    const funcCalls = (msg.tool_calls ?? []).filter(
      (tc): tc is OpenAI.ChatCompletionMessageFunctionToolCall => tc.type === "function"
    );

    if (funcCalls.length > 0) {
      if (msg.content) this.emit({ type: "thought", content: msg.content });
      // 并行执行工具,结果加入 messages,继续循环
    } else {
      // 没有工具调用 → 最终答案
      const answer = msg.content ?? "(空回答)";
      this.emit({ type: "answer", content: answer });
      return answer;
    }
  }
  return `达到最大步数限制(${this.maxSteps})`;
}

openai v5 的联合类型问题

编译时报了一个错:

python 复制代码
Type '"custom" | "function"' is not assignable to type '"function"'.

查了一下,openai v5 把 ChatCompletionMessageToolCall 定义成了联合类型:

typescript 复制代码
type ChatCompletionMessageToolCall =
  | ChatCompletionMessageFunctionToolCall   // type: "function", 有 .function 属性
  | ChatCompletionMessageCustomToolCall     // type: "custom", 没有 .function 属性

直接访问 tc.function.name 编译不过,因为 CustomToolCall 上没有 function 属性。加了个类型守卫过滤一下就好了:

typescript 复制代码
const funcCalls = (msg.tool_calls ?? []).filter(
  (tc): tc is OpenAI.ChatCompletionMessageFunctionToolCall =>
    tc.type === "function"
);

Python 版不存在这个问题 --- 动态类型不做编译检查。但反过来说,如果运行时真拿到一个 custom 类型的 tool_call,Python 会直接 AttributeError,TS 在编译期就拦住了。

事件系统替代 print

Python 版想看推理过程就是在 Agent 循环里加 print。能用,但 Agent 内部和输出方式绑死了。

TS 版改成事件:

typescript 复制代码
// Agent 内部
this.emit({ type: "thought", content: msg.content });
this.emit({ type: "tool_call", name: funcName, args: funcArgs });
typescript 复制代码
// 外部自己决定怎么处理
agent.on((event) => {
  if (event.type === "thought") console.log(`💭 ${event.content}`);
  if (event.type === "tool_call") logger.info(`Tool: ${event.name}`, event.args);
});

想打印到终端就打印,想写日志就写日志,Agent 不管。改动不大,但如果将来要把 Agent 嵌到一个 Web 应用里,不用去改 Agent 内部代码。

thought 和 answer 分开

写完之后测试,发现最终答案会打印两遍。排查了一下,原因是不管有没有 tool_calls,只要 msg.content 有值就 emit 了 thought。然后在没有 tool_calls 的分支里又 emit 了 answer。同一段内容被发了两次。

修起来很简单 --- 把 thought 的 emit 移到"有工具调用"的分支里:

typescript 复制代码
if (funcCalls.length > 0) {
  if (msg.content) this.emit({ type: "thought", content: msg.content });
  // 执行工具...
} else {
  this.emit({ type: "answer", content: answer });
}

有 tool_calls 时 content 是思考过程,没有 tool_calls 时 content 是最终答案。分开处理就不会重复了。

并行工具执行

DeepSeek 有时候一次返回多个 tool_calls --- 比如同时查北京和上海天气。Python 版是串行跑的:

python 复制代码
for tc in msg.tool_calls:
    result = TOOL_FUNCTIONS[func_name](**func_args)

TS 版用 Promise.all

typescript 复制代码
const toolPromises = funcCalls.map(async (tc) => {
  const result = await this.toolRegistry.execute(funcName, funcArgs);
  return { id: tc.id, result };
});
const toolResults = await Promise.all(toolPromises);

两个各 200ms 的工具调用,串行 400ms,并行 200ms。工具之间没有数据依赖的时候差别挺明显。


入口和 REPL

index.ts 做两件事:作为库导出所有组件,以及直接运行时启动一个交互式 REPL。

启动流程比较直白:

typescript 复制代码
// 读环境变量
const client = new OpenAI({ apiKey: process.env.DEEPSEEK_API_KEY, baseURL: "https://api.deepseek.com" });

// 注册 4 个演示工具(和 Python notebook 里一样的 get_weather、calculate、search_knowledge、get_time)
const registry = new ToolRegistry();
registerDemoTools(registry);

// 可选:连 MCP Server(环境变量里配了就连,没配就跳过)
if (process.env.MCP_SERVER_COMMAND && process.env.MCP_SERVER_ARGS) {
  await registry.connectMCP(mcpCommand, mcpArgs.split(" "));
}

// 创建 Agent,注册事件监听,启动 REPL
const agent = new Agent({ client, model: "deepseek-chat", maxSteps: 10 }, registry);
agent.on((event) => { /* 打印推理过程 */ });

跑起来的效果

多步推理

css 复制代码
❓ 广州现在几度?如果气温降低 20%,会是多少度?

  💭 [Thought] 用户问广州温度,我需要调 get_weather
  🔧 调用: get_weather({"city":"广州"})
  📋 结果: 雷阵雨,33°C,湿度 85%

  💭 [Thought] 广州 33°C,降低 20% 就是 33 × 0.8,用 calculate 确认
  🔧 调用: calculate({"expression":"33 * 0.8"})
  📋 结果: 26.400000000000002

✅ 广州当前 33°C,降低 20% 后约 26.4°C

三轮:查天气 → 提取温度 → 计算。

并行调用

css 复制代码
❓ 北京和上海哪个城市更热?热多少度?

  💭 [Thought] 需要查两个城市的天气,两个查询独立,可以同时进行
  🔧 调用: get_weather({"city":"北京"})
  🔧 调用: get_weather({"city":"上海"})
  📋 结果: 晴天,28°C,湿度 45%
  📋 结果: 多云,31°C,湿度 72%

✅ 上海更热,比北京热 3°C

一次返回两个 tool_calls,Promise.all 并行执行。

多轮记忆

css 复制代码
❓ 北京天气怎么样?
✅ 北京:晴天,28°C

❓ 刚才北京多少度来着?
  💭 [Thought] 回顾对话历史,第一次查询时获取了北京天气:28°C
✅ 北京当时的温度是 28°C

没有重新调工具,从对话历史里直接拿到了之前的结果。


和 Python 版比改了什么

做完之后回头看,TS 版相对 Python notebook 主要改了这几个地方:

工具注册不用维护两份了。Python 版 TOOLS dict 和 FC_TOOLS list 要手动同步,TS 版 ToolRegistry 注册一次自动生成两种格式。

工具来源不再局限于本地函数。通过 MCP Client 可以动态接入 MCP Server 暴露的工具,Agent 不需要知道工具从哪来。

对话历史跨调用保持。Python 版 react_agent_fc() 每次调用是独立的,messages 不会保留到下一次。TS 版 Agent 类的 this.messages 会一直累积,所以能回答"刚才说了什么"这类问题。

工具并行执行。Python 的 for 循环串行跑工具,TS 版 Promise.all 并行。

可观测性从 print 变成了事件系统。Agent 内部只管 emit 事件,外面爱怎么处理怎么处理。

类型问题在编译期就暴露了。Python 版 ChatCompletionMessage 对象不是 dict 的坑,在 TS 版里不存在。

已知的问题

写的时候有意跳过了几个东西:

MCP 工具返回的 content 数组里只处理了 text 类型。如果 MCP Server 返回图片(type: "image"),当前代码会直接丢掉。现在接的都是自己写的 Server 所以没事,接第三方的时候得加。

本地工具和 MCP 工具同名会直接覆盖,没有警告也没有前缀隔离。

工具报错只是把错误信息字符串喂回给 LLM,没有代码层面的自动重试。

没有流式输出,LLM 说完才开始处理。

对话历史纯内存,进程一关就没了。


小结

整个框架写下来大概 500 行。写之前觉得 "从 Python 搬到 TS" 应该很快,实际做的时候发现不是搬代码的问题 --- 怎么统一工具接口、怎么接 MCP、怎么把推理过程暴露出去,这些设计层面的决定比写代码本身花的时间更多。Agent 循环本身确实就是个 while 循环,不复杂。但是围绕这个循环的工程工作 --- 工具管理、上下文控制、可观测性 --- 才是把 demo 变成可复用框架的关键。

相关推荐
林语琛1 小时前
我写的 switch…break 被 Babel 偷偷吞了
前端·javascript·babel
烈风逍遥1 小时前
第六篇:RAG 知识库构建与检索全链路
前端·人工智能·后端
爱丶不疚1 小时前
什么是 Jev 决策模型?它适合干什么?
前端·agent
江畔柳前堤1 小时前
字节跳动·大模型应用知识手册
前端·人工智能·深度学习·opencv·目标检测·重构·transformer
LEE2 小时前
前端转型全栈 03:接口失败也返回 200,OpenAPI 契约与错误码怎么定
前端·后端·全栈
我命由我123452 小时前
CSS - CSS 媒体查询 orientation
前端·javascript·css·html·css3·html5·js
lichenyang4533 小时前
让 VK 小程序调用 HarmonyOS 原生能力:一次跨端 Bridge SDK 的设计与实践
前端
阳光宅男@李光熠3 小时前
【电子通识】一起学习TDK的EMC基础——电池兼容设计方法概述
java·前端·数据库
南雨北斗4 小时前
vue3项目中的env.d.ts文件
前端