之前用 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) };
}
返回值带了 trimmed、beforeTokens、afterTokens,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 变成可复用框架的关键。