1. 为什么我要自己造一个 Agent 框架
市面上现成的 Agent 框架已经不少了,LangChain、AutoGen、CrewAI,还有 OpenAI 官方出的 Agents SDK,功能一个比一个全。但我还是花了两个周末,用 TypeScript 从零搭了一个能跑通 ReAct 循环、能接 MCP 工具、能流式输出的最小 Agent 框架。原因很简单:用别人的框架,你永远不知道 Agent 到底在干什么。
我最早用某个流行框架做一个"读取本地文件并总结"的小工具,结果它跑了 40 秒,烧了 3 万 token,最后告诉我"无法访问文件系统"。我翻日志翻了半小时才发现,是工具注册时 schema 描述写得太模糊,模型压根没选对工具,而框架把这次失败静默吞掉了。那一刻我就决定,核心链路必须自己写一遍。
这篇文章不是教你"如何用某个框架",而是带你从零把 Agent 的骨架搭出来。读完你会清楚:一次 Agent 调用里,消息是怎么组织的、工具是怎么被模型选中的、ReAct 循环什么时候该停、MCP 协议到底解决了什么问题。适合有 TypeScript 基础、想真正搞懂 Agent 内部机制的前端或全栈开发者。不需要你懂 Python,也不需要你装一堆依赖,一个 Node 环境加一个 OpenAI API Key 就能跑起来。
我先把结论放前面:Agent 的本质就是一个带工具调用的 while 循环。听起来简单,但魔鬼全在细节里——消息历史怎么裁剪、工具调用失败怎么重试、流式输出怎么和工具执行穿插、循环什么时候该强制退出。这些细节,才是框架真正值钱的地方,也是你自己写一遍才能掌握的东西。
2. 动手前的技术选型:为什么是 TypeScript 而不是 Python
2.1 TypeScript 做 Agent 的三个真实优势
很多人默认 Agent 开发就该用 Python,毕竟生态在那。但我实际写下来,TypeScript 在这个场景里有几个被低估的优势。
第一是类型系统对工具 schema 的约束。Agent 的核心是工具调用,而工具的参数结构必须和 JSON Schema 严格对应。TypeScript 的zod配合类型推导,可以做到"定义一次,schema 和类型同时生成",模型传错参数在编译期就能发现一部分问题。Python 的 Pydantic 也能做,但 TS 的类型收窄在编辑器里的体验更顺滑。
第二是流式处理天然契合。Agent 的输出是 SSE 流,工具执行是异步的,前端展示又要实时。Node 的ReadableStream和AsyncGenerator处理这种"边收边处理"的场景非常自然,我整个流式解析器不到 80 行就写完了。
第三是部署链路短。写完之后直接tsc编译,扔到一个 Node 服务或者 Serverless 函数里就能跑,不用折腾虚拟环境和依赖隔离。对于做前端出身、想快速验证 Agent 想法的人来说,这条路径最短。
2.2 依赖清单:能少则少
我给自己定的规矩是:核心依赖不超过 3 个。最终清单是这样的:
| 依赖 | 用途 | 是否可替代 |
|---|---|---|
openai | 调用模型 API,官方 SDK 对流式和工具调用支持最完整 | 可换成任意兼容 OpenAI 协议的 SDK |
zod | 定义工具参数 schema,同时推导 TS 类型 | 可换成ajv,但类型体验差很多 |
tsx | 开发时直接跑 TS,免编译 | 可换成ts-node或先tsc |
注意这里我没有引入任何 Agent 框架,也没有引入 MCP 的官方 SDK。MCP 部分我会手写 JSON-RPC 消息,因为协议本身很简单,手写一遍你才能真正理解它。
提示:如果你用的是兼容 OpenAI 协议的其他模型服务,只需要改
baseURL和apiKey两个参数。但要注意,不同服务对tool_calls字段的支持程度不一样,有些会把流式工具调用拆得很碎,后面第 5 节我会讲怎么处理。
2.3 项目结构:按职责切,不按技术切
我不喜欢按controllers/services/utils这种技术维度切目录,Agent 项目应该按职责切:
src/ agent/ loop.ts # ReAct 主循环 message.ts # 消息类型与历史管理 stream.ts # 流式响应解析 tools/ registry.ts # 工具注册中心 builtin/ # 内置工具(读文件、计算等) mcp/ client.ts # MCP 客户端 transport.ts # 传输层(stdio / http) llm/ client.ts # 模型调用封装 index.ts # 入口这样切的好处是,当你想换模型、换工具、换传输方式时,改动范围是局部的。我后来把模型从一家换到另一家,只动了llm/client.ts一个文件。
3. 消息模型:Agent 的骨架其实是消息数组
3.1 四种角色,各有各的坑
OpenAI 的消息模型有四种角色:system、user、assistant、tool。看起来简单,但实际用起来有几个反直觉的地方。
system消息不是必须放在第一条。我试过把工具使用规范放在对话中间作为一条 system 消息,模型确实会更重视它。但要注意,有些模型服务只认第一条 system,后面的会被忽略,所以关键约束还是放开头稳妥。
assistant消息里可以同时有content和tool_calls。这是最容易出错的地方——当模型决定调用工具时,content往往是null或者空字符串,但这条消息必须原样存进历史,因为下一轮你要把tool角色的结果和它配对。我一开始图省事,只存了工具结果没存 assistant 的 tool_calls,结果模型完全不知道自己在等什么,直接开始胡说。
tool消息必须带tool_call_id,而且要和对应的assistant.tool_calls[].id严格匹配。这个 id 是模型生成的,你只管透传,千万别自己造。
3.2 消息历史管理:裁剪比追加更重要
Agent 跑多轮之后,消息历史会迅速膨胀。一个读文件加总结的任务,轻松就能堆到 20 条消息、上万 token。如果不做裁剪,成本和延迟都会失控。
我的策略是分层保留:
- 永久保留:第一条 system 消息、最近 N 轮完整对话(N 默认 6)
- 压缩保留:更早的 assistant 文本回复,合并成一条摘要
- 直接丢弃:更早的 tool 调用细节,只保留最终结论
这里有个坑:裁剪时不能把 assistant 的 tool_calls 和对应的 tool 结果拆散。如果保留了一条 tool 消息却丢了它的 tool_calls,API 会直接报错。我的做法是裁剪的最小单位是"一个完整的工具调用回合",要么整组留,要么整组丢。
// 按回合裁剪,保证 tool_calls 和 tool 结果成对出现 function trimHistory(messages: Message[], keepRounds = 6): Message[] { const system = messages.filter(m => m.role === 'system'); const rest = messages.filter(m => m.role !== 'system'); // 从后往前数完整的回合 const rounds: Message[][] = []; let current: Message[] = []; for (const msg of rest) { current.push(msg); if (msg.role === 'assistant' && !msg.tool_calls) { rounds.push(current); current = []; } } if (current.length) rounds.push(current); const kept = rounds.slice(-keepRounds).flat(); return [...system, ...kept]; }3.3 token 估算:别等 API 报错才后悔
精确算 token 需要 tokenizer,但那个库不小。我用的是粗略估算:英文按 4 字符 1 token,中文按 1.5 字符 1 token,工具 schema 单独算。这个估算误差在 15% 以内,足够用来做"要不要裁剪"的判断。
真正要卡的是硬上限。我会在每次调用前检查估算值,超过模型上下文窗口的 70% 就触发裁剪。留 30% 余量是因为模型的输出也要占 token,而且工具调用的 JSON 有时候会很长。
4. 工具系统:让模型"会用"比"能用"更重要
4.1 工具定义的三要素
一个工具要能被模型正确调用,必须说清三件事:叫什么、干什么、参数长什么样。前两个靠name和description,第三个靠parameters的 JSON Schema。
我踩过最大的坑就是description写得太随意。比如我写了个readFile工具,描述是"读取文件"。结果模型在需要读目录的时候也调它,因为描述里没说清楚它只能读文件不能读目录。后来我改成"读取指定路径的单个文件内容,路径必须是文件而非目录,如需列出目录请用 listDir",命中率立刻上来了。
description 要写成给一个完全不懂你项目的人看的说明书,把边界条件、失败情况、替代工具都写进去。这不是啰嗦,这是在给模型做 prompt engineering。
4.2 用 zod 一次定义,schema 和类型同时出
手写 JSON Schema 又长又容易和实际类型对不上。我用 zod 做单一数据源:
import { z } from 'zod'; const ReadFileParams = z.object({ path: z.string().describe('要读取的文件绝对路径'), encoding: z.enum(['utf8', 'base64']).default('utf8') .describe('文件编码,文本文件用 utf8,二进制用 base64'), }); type ReadFileParams = z.infer<typeof ReadFileParams>; const readFileTool = { name: 'readFile', description: '读取指定路径的单个文件内容...', parameters: zodToJsonSchema(ReadFileParams), execute: async (args: ReadFileParams) => { // 这里 args 已经是强类型的 return await fs.readFile(args.path, args.encoding); } };zodToJsonSchema需要自己写一个转换函数,或者用现成的库。我手写了一个简化版,只支持 object、string、enum、number、boolean 这几种常用类型,30 行搞定,比引库轻。
4.3 工具执行失败:别把异常直接扔给模型
工具执行出错时,有两种处理方式:抛异常中断整个循环,或者把错误信息作为工具结果返回给模型。我强烈建议后者。
原因很实际:模型调用工具时经常传错参数,比如路径写错、格式不对。如果你直接抛异常,整个 Agent 就挂了,用户看到的是报错。但如果你把错误信息返回,模型下一轮往往能自己纠正。我实测下来,路径类错误模型自我修正的成功率超过 70%。
async function safeExecute(tool: Tool, args: unknown) { try { const parsed = tool.schema.parse(args); // zod 校验 const result = await tool.execute(parsed); return { success: true, content: String(result) }; } catch (err) { // 把错误变成模型能读懂的文字 return { success: false, content: `工具执行失败:${(err as Error).message}。请检查参数后重试。` }; } }注意错误信息要对模型友好,别把一长串堆栈扔进去,那只会浪费 token。提炼成一句人话就够了。
4.4 工具数量不是越多越好
我一开始注册了 15 个工具,结果模型选择困难,经常选错。后来砍到 5 个核心工具,准确率明显提升。经验值是:单次对话暴露给模型的工具不超过 8 个,超过就要考虑分组或者按需加载。
如果确实工具很多,可以用"工具检索"的思路:先让模型根据任务描述选出一组相关工具,再把这组工具的完整 schema 给它。这样既控制了上下文长度,又提高了选择准确率。
5. ReAct 循环:Agent 的心跳
5.1 循环的四个阶段
ReAct 的核心是"推理-行动-观察"的循环。落到代码上,每一轮是这样的:
- 组装消息:把历史消息加上当前状态,发给模型
- 解析响应:看模型是返回文本还是工具调用
- 执行工具:如果有工具调用,逐个执行,收集结果
- 判断终止:如果模型返回纯文本,说明它认为任务完成,循环结束
听起来简单,但每一步都有细节。比如第 2 步,模型可能同时返回文本和工具调用,这时候文本通常是它的"思考过程",要保留但不能当作最终答案。
5.2 终止条件:三种情况必须停
循环不能无限跑,我设了三个终止条件:
- 正常终止:模型返回了没有 tool_calls 的 assistant 消息
- 轮次上限:默认 10 轮,超过就强制停,返回当前所有文本
- 重复检测:连续两轮调用了完全相同的工具和参数,判定为死循环,强制停
第三个条件特别重要。我遇到过模型陷入"读文件-发现读不了-再读一次"的死循环,烧了 5 万 token 才发现。加上重复检测后,这类问题基本绝迹。
function isDuplicate(prev: ToolCall[], curr: ToolCall[]): boolean { if (prev.length !== curr.length) return false; return prev.every((p, i) => p.function.name === curr[i].function.name && p.function.arguments === curr[i].function.arguments ); }5.3 流式输出与工具执行的穿插
这是整个框架里最绕的部分。流式响应里,文本是逐字来的,但工具调用是分片来的——tool_calls的arguments会分成好几个 chunk 传过来,你得自己拼。
我的处理方式是维护一个"工具调用累加器":
const toolCallBuffer = new Map<number, { name: string; args: string }>(); for await (const chunk of stream) { const delta = chunk.choices[0]?.delta; if (delta?.content) { // 文本直接推给前端 yield { type: 'text', content: delta.content }; } if (delta?.tool_calls) { for (const tc of delta.tool_calls) { const idx = tc.index; if (!toolCallBuffer.has(idx)) { toolCallBuffer.set(idx, { name: '', args: '' }); } const buf = toolCallBuffer.get(idx)!; if (tc.function?.name) buf.name += tc.function.name; if (tc.function?.arguments) buf.args += tc.function.arguments; } } }关键点:index字段是分片的唯一标识,同一个工具调用的所有分片 index 相同。拼完之后再JSON.parse(buf.args),这时候才能执行工具。
注意:有些模型服务在流式模式下不返回
index,或者把name和arguments混在一个 chunk 里。遇到这种情况,我的兜底方案是:如果index缺失,就按id分组;如果id也缺失,就假设只有一个工具调用。这个兜底逻辑救过我好几次。
5.4 工具执行要不要并行
如果模型一轮里调了多个工具,理论上可以并行执行。但我的建议是默认串行,除非你确定这些工具之间没有依赖。
原因很实际:并行执行时,如果其中一个工具修改了文件,另一个工具读同一个文件,结果就不确定了。而且并行执行会让日志变得难以追踪。我只有在明确知道工具是只读的情况下才开并行。
6. 接入 MCP:让 Agent 用上外部工具生态
6.1 MCP 到底解决了什么问题
MCP(Model Context Protocol)说白了就是一套让模型和外部工具对话的标准协议。在它出现之前,每个 Agent 框架都有自己的工具定义格式,你想把一个工具从 A 框架搬到 B 框架,得重写一遍。MCP 把这个格式统一了,工具提供方只要实现一次 MCP Server,所有支持 MCP 的客户端都能用。
它的通信基于 JSON-RPC 2.0,消息格式很朴素。核心方法就几个:initialize握手、tools/list列工具、tools/call调工具。理解了这三个,你就理解了 MCP 的 80%。
6.2 手写一个最小 MCP 客户端
我不建议一上来就用官方 SDK,手写一遍你会对协议有肌肉记忆。核心逻辑是这样的:
class McpClient { private nextId = 1; private pending = new Map<number, (result: any) => void>(); async request(method: string, params: any): Promise<any> { const id = this.nextId++; const message = { jsonrpc: '2.0', id, method, params }; this.send(message); return new Promise((resolve) => { this.pending.set(id, resolve); }); } private handleMessage(raw: string) { const msg = JSON.parse(raw); if (msg.id && this.pending.has(msg.id)) { this.pending.get(msg.id)!(msg.result); this.pending.delete(msg.id); } } }握手流程是:先发initialize,拿到 server 的能力声明,再发notifications/initialized通知。然后就能tools/list拿工具列表,把 MCP 工具转换成我们框架内部的工具格式。
6.3 传输层:stdio 和 HTTP 的取舍
MCP 支持多种传输方式,最常用的是 stdio 和 HTTP。
stdio适合本地工具,比如文件操作、执行命令。客户端启动一个子进程,通过标准输入输出通信。优点是简单、无需网络、权限可控。缺点是只能本地用,跨机器不行。
HTTP适合远程工具,比如调用某个云服务。优点是跨网络,缺点是得处理鉴权、超时、重连。
我的框架里两种都支持,通过配置切换。stdio 的实现要注意消息边界——MCP 用换行分隔消息,但 JSON 里可能有换行,所以得按行读再拼。我一开始没处理这个,遇到多行 JSON 就解析失败。
// stdio 传输:按行读取,处理跨行 JSON const rl = readline.createInterface({ input: child.stdout }); let buffer = ''; rl.on('line', (line) => { buffer += line; try { const msg = JSON.parse(buffer); buffer = ''; this.handleMessage(msg); } catch { // 解析失败说明 JSON 还没收完,继续攒 } });6.4 MCP 工具的 schema 转换
MCP 返回的工具定义里,inputSchema就是标准 JSON Schema,可以直接转成 OpenAI 的parameters格式。但有个细节:MCP 的 schema 里可能包含$schema、title这些字段,OpenAI 不认,得过滤掉。
function mcpToolToOpenAI(tool: McpTool) { const { $schema, title, ...params } = tool.inputSchema; return { type: 'function', function: { name: tool.name, description: tool.description, parameters: params, } }; }转换完之后,MCP 工具就和内置工具一样,统一进注册中心,模型根本分不清哪个是内置的、哪个是 MCP 来的。这就是协议标准化的好处。
7. 实测中踩过的坑与排查思路
7.1 模型不调工具,只输出文本
这是最常见的问题。模型明明该调工具,却直接给你一段"我建议你这样做"的文本。排查顺序是这样的:
先看工具描述是否清晰。如果描述模糊,模型会倾向于不调。我遇到过一次,工具叫search,描述是"搜索",模型完全不知道搜什么、怎么搜,干脆不调。改成"在项目代码库中按关键词搜索文件内容,返回匹配的文件路径和行号"之后,立刻就调了。
再看system prompt 有没有明确要求。如果 system 里没提"你可以使用工具",有些模型会保守地不调。加一句"当需要获取外部信息时,优先使用提供的工具"能显著提升调用率。
最后看模型本身。不同模型对工具调用的积极性差别很大。同一个 prompt,有的模型调得很勤,有的几乎不调。这个只能靠实测选型。
7.2 工具参数 JSON 解析失败
流式拼接时,arguments拼出来的字符串偶尔不是合法 JSON。原因通常是分片顺序错乱,或者某个分片丢了。
我的排查方法是把原始分片全部打日志,看拼接过程。有一次发现某个服务的分片里,arguments字段是null而不是空字符串,我直接+=就变成了"null{...}",自然解析失败。加上判空之后就正常了。
兜底方案是:解析失败时,尝试用正则提取出 JSON 部分,或者直接把原始字符串作为错误信息返回给模型,让它重新生成参数。
7.3 循环停不下来
前面提过重复检测,但还有一种情况:模型每轮调用的工具不同,但目标始终达不到。比如它想读一个不存在的文件,先试readFile,失败后试listDir,再试readFile换个路径,来回折腾。
我的处理是加一个总 token 预算。每次调用前累加估算 token,超过预算就强制终止,返回"任务因超出预算而中断"。这个预算默认设 5 万 token,对大多数任务够用,也能防止失控。
7.4 流式输出和工具执行顺序错乱
前端展示时,用户希望看到"模型思考-调用工具-工具结果-继续思考"的顺序。但如果工具执行是异步的,而文本流还在继续,顺序就会乱。
我的做法是在流里插入事件标记。文本 chunk 推text事件,工具开始执行推tool_start事件,工具结束推tool_end事件。前端按事件顺序渲染,就不会乱。这个设计后来证明非常有用,前端可以据此做"工具执行中"的 loading 状态。
8. 一些让框架更好用的经验补充
8.1 日志要能还原完整决策链
Agent 出问题时,你需要的不是"报错了",而是"模型为什么这么决策"。我的日志记录了每一轮的完整消息、模型的原始响应、工具调用的参数和结果。这样出问题时,我能像看录像一样回放整个决策过程。
日志格式我用 JSON Lines,每行一个事件,方便用jq过滤。比如查所有工具调用:cat agent.log | jq 'select(.type=="tool_call")'。
8.2 给模型一个"思考"字段
我在工具 schema 里加了一个可选的reasoning参数,让模型在调用工具前先说明为什么调这个工具。这个字段不参与实际执行,但会出现在日志里。实测下来,加了之后模型的工具选择准确率有提升,因为"说出来"本身就是一种约束。
8.3 超时控制要分层
模型调用、工具执行、整个循环,三层都要有超时。模型调用超时设 60 秒,工具执行超时设 30 秒,整个循环超时设 5 分钟。任何一层超时都要能优雅退出,返回已完成的部分结果,而不是直接崩掉。
8.4 别急着上多 Agent
我见过太多项目一上来就搞多 Agent 协作,结果调试成本爆炸。单 Agent 加好工具,能解决 80% 的问题。多 Agent 的价值在于任务可以明确拆分且需要不同专长时,否则只是徒增复杂度和 token 消耗。我的建议是:先把单 Agent 循环跑稳,工具生态建好,真的遇到瓶颈了再考虑多 Agent。
8.5 版本兼容的坑
TypeScript 生态变化快,有些配置项会弃用。比如baseUrl选项在较新版本里已经标记弃用,未来会移除。如果你在tsconfig.json里用了它做路径别名,建议尽早迁移到paths配合moduleResolution: "bundler"的方案。这类问题不影响功能,但会在编译时刷一堆警告,早点处理省心。
9. 写在最后的一点个人体会
这个框架我从第一版能跑到现在稳定用,前后改了七八轮。最大的收获不是代码本身,而是对 Agent 行为的理解。以前用现成框架,模型不调工具我就换 prompt,调错了我就加描述,全靠试。自己写完之后,我能从消息历史、工具 schema、循环状态三个层面去定位问题,效率完全不一样。
如果你也想动手,我的建议是先跑通最小闭环:一个模型、一个工具、一个循环,能完成"读文件并总结"就行。别一上来就搞 MCP、搞多 Agent、搞复杂工具链。最小闭环跑通之后,你会发现后面加什么都是在这个骨架上挂东西,心里特别有底。
代码我整理成了一个不到 600 行的仓库,核心逻辑都在agent/loop.ts和tools/registry.ts两个文件里。真正值钱的不是行数,是每一行背后"为什么这么写"的判断。这些判断,只有你自己写一遍才会长在身上。