这次我们直接看一个很具体的问题:如何用 TypeScript 把通用智能体跑起来,并且把核心代码控制在 100 行左右。这里说的通用智能体,不是某个重量级框架,而是 Agent 最核心的闭环:模型负责理解任务、规划步骤,代码负责执行工具、回传结果,模型根据结果继续推理,直到给出最终答案。这个闭环一旦跑通,后续加记忆、加规划、加知识库都只是扩展问题。
我选择 TypeScript 来做这个示例,有几个原因:第一,它不依赖任何 Agent 框架,只依赖 Node.js 环境和 TypeScript 本身;第二,代码直接对接 OpenAI 兼容的 Chat Completions 接口,只要模型服务支持tools参数,就能接入,不管是云端模型还是本地推理服务;第三,消息结构、工具参数、返回结果都有类型约束,改起来比纯 JavaScript 清晰很多。本文会给出完整代码、运行测试流程、接口封装示例和批量任务处理思路,也会把最容易踩的坑列出来。如果你正在做智能体开发,或者想弄明白各种 Agent 框架底层是怎么工作的,这篇文章值得收藏。
1. 核心能力速览
| 能力项 | 说明 |
|---|---|
| 项目类型 | TypeScript 轻量通用智能体示例 |
| 依赖环境 | Node.js 18+、TypeScript、支持tools参数的大模型接口 |
| 主要功能 | 多步推理、工具调用、消息历史维护、可扩展工具集 |
| 推荐硬件 | 调用云端模型无特殊硬件要求;使用本地模型时按模型要求配置 |
| 显存占用 | 取决于接入的模型,本示例代码本身只占用少量内存 |
| 支持平台 | Windows、Linux、macOS |
| 启动方式 | 命令行脚本,可扩展为 HTTP API 服务 |
| 是否支持 API | 可以封装为 HTTP 接口,文中提供封装思路 |
| 是否支持批量任务 | 支持,可编写批处理脚本并发执行 |
| 适合场景 | Agent 原理学习、原型验证、自动化任务、内部工具集成 |
这里要说明一点:这个项目本身是一个教学型示例,目的是把 Agent 的工具调用机制讲清楚。它不追求生产级高并发,也不内置复杂的记忆和规划模块,但核心循环和接口设计是通用的。你完全可以在它基础上继续扩展。
2. 适用场景与使用边界
这个实现适合三类人。第一类是刚开始接触智能体开发的工程师,想跳过漫长框架文档,直接看一个最小可运行样本;第二类是已经有业务系统的开发同学,需要在自己的 TypeScript 项目里嵌入一个轻量 Agent,对外提供工具调用能力;第三类是想基于本地模型做实验的玩家,只要本地推理服务支持 OpenAI 兼容接口,就可以用同样代码接入。
它不适合的场景也很明显。如果业务需要处理超长上下文、多智能体协作、复杂记忆管理、高并发生产流量,这个 100 行示例不够,应该去评估成熟的 Agent 框架或云平台能力。另外,工具调用越强大,越要注意安全边界。示例里只放了获取时间和数字加法,可以在本地放心运行;但如果要把工具扩展成执行命令、读写文件、调用外部系统,就必须做好权限校验和操作审计。涉及用户数据、版权素材、人脸声音等敏感内容时,要确保已获得合法授权,不能因为“技术能跑通”就忽略合规要求。
3. 环境准备与前置条件
首先确认本机 Node.js 版本。代码里用到原生fetch,Node.js 18 开始默认支持,所以建议使用 Node.js 18 或更高版本。打开终端检查:
node -v npm -v如果没有安装 Node.js,先去官网下载 LTS 版本,安装完成后再次检查版本即可。
接着创建项目并安装依赖。这里需要三个基础包:typescript用于编译,tsx用于直接运行 TypeScript 文件,dotenv用于读取.env配置文件。@types/node提供 Node.js 环境类型提示。
mkdir ts-agent-demo cd ts-agent-demo npm init -y npm install typescript tsx dotenv npm install -D @types/node然后创建一个tsconfig.json,指定编译目标。这里用ES2022是因为代码里会用到String.prototype和异步迭代等现代语法,也可以用更保守的目标,但建议直接使用 ES2022 以上。
{ "compilerOptions": { "target": "ES2022", "module": "ESNext", "moduleResolution": "Bundler", "strict": true, "esModuleInterop": true, "skipLibCheck": true, "types": ["node"] } }如果运行批量任务时用到顶层await,需要把module设置为ESNext或在tsx环境下运行。也可以用module: "CommonJS"搭配node运行编译后的 JS,但为了简单,下面所有例子都直接用tsx运行。
环境变量方面,准备一个.env文件。如果使用云端模型,需要填写 API Key;如果使用本地兼容接口,只需要把地址指向本地服务。模型名根据实际部署填写,不同模型对tools的支持程度不一样,建议选择支持 function calling 或工具调用的模型。
4. 100行代码实现通用智能体:核心代码
先理清设计思路。一个通用智能体最少需要四部分:消息结构、工具定义、模型调用函数、主循环。消息结构用来保存 system、user、assistant、tool 四种角色的消息;工具定义告诉模型有哪些函数可以调用;模型调用函数负责把消息和工具列表发给大模型;主循环负责判断模型返回的是普通文本还是工具调用,如果是工具调用就执行并把结果追加到消息历史里,然后再次调用模型,直到模型输出最终答案。
下面是完整的示例代码,保存为agent.ts:
import { config } from "dotenv"; config(); const API_KEY = process.env.API_KEY ?? ""; const BASE_URL = process.env.BASE_URL ?? "https://api.openai.com/v1"; const MODEL = process.env.MODEL ?? "gpt-4o-mini"; type ToolCall = { id: string; type: "function"; function: { name: string; arguments: string }; }; type Message = { role: "system" | "user" | "assistant" | "tool"; content: string; tool_call_id?: string; tool_calls?: ToolCall[]; }; type Tool = { name: string; description: string; parameters: Record<string, unknown>; execute: (args: any) => string | Promise<string>; }; const tools: Tool[] = [ { name: "get_current_time", description: "获取当前日期和时间", parameters: { type: "object", properties: {} }, execute: () => new Date().toLocaleString(), }, { name: "add_numbers", description: "计算两个数字的和", parameters: { type: "object", properties: { a: { type: "number", description: "第一个数字" }, b: { type: "number", description: "第二个数字" }, }, required: ["a", "b"], }, execute: ({ a, b }) => String(a + b), }, ]; async function callLLM(messages: Message[]): Promise<Message> { const res = await fetch(`${BASE_URL}/chat/completions`, { method: "POST", headers: { "Content-Type": "application/json", Authorization: `Bearer ${API_KEY}`, }, body: JSON.stringify({ model: MODEL, messages, tools: tools.map(({ name, description, parameters }) => ({ type: "function", function: { name, description, parameters }, })), }), }); if (!res.ok) throw new Error(await res.text()); const data = await res.json(); return data.choices[0].message; } async function runAgent(userInput: string, maxSteps = 5): Promise<string> { const messages: Message[] = [ { role: "system", content: "你是通用智能体,可以调用工具完成任务,回答要简洁。" }, { role: "user", content: userInput }, ]; for (let step = 0; step < maxSteps; step++) { const reply = await callLLM(messages); messages.push(reply); if (!reply.tool_calls?.length) return reply.content; for (const toolCall of reply.tool_calls) { const tool = tools.find((t) => t.name === toolCall.function.name); if (!tool) { messages.push({ role: "tool", tool_call_id: toolCall.id, content: `未知工具: ${toolCall.function.name}` }); continue; } let result: string; try { const args = JSON.parse(toolCall.function.arguments || "{}"); result = String(await tool.execute(args)); } catch (err) { result = `工具执行失败: ${(err as Error).message}`; } messages.push({ role: "tool", tool_call_id: toolCall.id, content: result }); } } return "已达到最大步骤数,任务未完成。"; } async function main() { const input = process.argv.slice(2).join(" ") || "现在几点了?"; console.log(await runAgent(input)); } main().catch((err) => { console.error(err); process.exit(1); });这段代码去掉空行和类型定义,核心循环逻辑确实在 100 行左右。重点不是代码行数,而是这个结构可以复用到很多项目里。
4.1 消息类型定义
Message类型对应 Chat Completions 接口的消息格式。role可以是system、user、assistant、tool。当模型返回工具调用时,assistant消息会带上tool_calls数组;当代码执行完工具后,需要往消息列表里追加一条role: "tool"的消息,并且通过tool_call_id关联到对应的工具调用请求。这个对应关系容易出错,少了tool_call_id很多模型会直接报错。
由于接口返回的message里可能同时有content和tool_calls,这里把Message设计成可选字段。主流模型的返回结构基本一致,即使换了模型服务商,也只需要微调类型定义。
4.2 工具注册与执行
tools数组就是智能体可以使用的工具列表。每个工具包含四个字段:名字、描述、参数 JSON Schema、执行函数。描述非常关键,模型靠描述判断什么时候该调用哪个工具。参数 Schema 必须用 JSON Schema 格式,模型会参考它生成合法的参数 JSON。
示例里放了两个工具:get_current_time获取当前时间,add_numbers计算两个数字的和。execute函数可以是同步的,也可以是异步的。实际项目里,可以把execute改成任何你想让智能体执行的操作,比如查数据库、调业务接口、发消息等。但注意,工具能力越强,越要校验入参和输出,避免不可控操作。
4.3 主循环与多步推理
runAgent函数是核心。它先把 system 和 user 消息放进历史,然后进入循环。每次循环做三件事:调用模型、追加返回消息、判断是否有工具调用。如果没有工具调用,说明模型可以直接回答,返回content。如果有工具调用,就逐个执行,把工具结果追加到消息历史,然后继续下一次循环。
maxSteps参数用来限制最大推理步数。这个限制很重要,因为模型可能在复杂任务里反复调用工具,甚至陷入死循环。示例里默认是 5 步,实际使用可以按任务复杂度调整。多步推理能力是“通用智能体”的核心,它让模型不是一次生成完答案,而是可以根据工具返回结果动态调整下一步动作。
4.4 如何接入其他模型或本地推理服务
这段代码默认请求https://api.openai.com/v1,如果要用本地推理服务,只需要修改BASE_URL。比如本地部署了一个 OpenAI 兼容接口,监听在8000端口,那么.env里可以这样写:
BASE_URL=http://127.0.0.1:8000/v1 MODEL=你的本地模型名注意,不是所有模型都支持工具调用。如果你的模型不支持tools参数,接口会忽略该字段或返回错误。建议先看模型文档,确认它支持 function calling 或 tool use 能力。从实践来看,市面上的主流模型基本都支持,但参数格式可能存在细微差异,遇到问题时优先检查接口返回的错误信息。
5. 运行测试与效果验证
先创建.env文件:
API_KEY=你的密钥 BASE_URL=https://api.openai.com/v1 MODEL=你的模型名如果使用本地推理服务,把BASE_URL改成本地地址,并填入支持的模型名。不要真的把密钥写进代码或提交到 Git,.env要加入.gitignore。
运行第一个测试,让智能体查询时间:
npx tsx agent.ts "现在几点了?"正常结果是模型返回当前日期和时间。由于代码里没有加日志,默认只输出最终回答。如果想观察工具调用的过程,可以在main函数里打印中间消息,或者在runAgent中模型返回后加一段调试输出。
第二个测试,验证工具参数解析和计算能力:
npx tsx agent.ts "请计算 123456 + 654321"预期结果是777777。这个用例能说明模型成功生成了add_numbers工具调用,参数被正确解析,工具结果被传回模型,最终模型基于工具结果给出答案。
第三个测试,验证多步推理。可以故意问一个需要先拿时间再判断的问题:
npx tsx agent.ts "现在是几点?如果小时数大于12,请输出下午,否则输出上午"这会让模型先调用get_current_time,拿到结果后再根据小时数推理。判断成功标准是:最终答案正确,并且过程中发生了一次工具调用,模型没有一次性硬编答案。
常见失败原因有三个。一是 API Key 或 BASE_URL 配置错误,接口返回 401 或 404;二是模型不支持tools参数,返回 400;三是工具执行函数本身报错,比如参数解析失败。遇到这些问题,先看终端输出的异常信息,把接口返回的响应体打印出来,通常能直接定位。
6. 接口 API 与批量任务
上面的代码默认是命令行入口,适合手动测试。如果要把智能体接入到业务系统里,最直接的办法是封装成 HTTP API 服务。下面用 Node.js 原生http模块写一个极简示例,避免引入额外依赖:
import http from "http"; import { runAgent } from "./agent"; const server = http.createServer(async (req, res) => { if (req.method === "POST" && req.url === "/agent") { let body = ""; for await (const chunk of req) body += chunk; try { const { prompt } = JSON.parse(body); const answer = await runAgent(prompt); res.setHeader("Content-Type", "application/json"); res.end(JSON.stringify({ answer })); } catch (err) { res.statusCode = 500; res.end(JSON.stringify({ error: (err as Error).message })); } } else { res.statusCode = 404; res.end(); } }); server.listen(3000, () => { console.log("agent service running at http://127.0.0.1:3000"); });这里没有处理超时、并发限制、鉴权等生产级问题,但足以验证接口链路。启动服务后,可以用curl测试:
curl -X POST http://127.0.0.1:3000/agent \ -H "Content-Type: application/json" \ -d '{"prompt":"现在几点了?"}'也可以用 Python 调用:
import requests resp = requests.post( "http://127.0.0.1:3000/agent", json={"prompt": "请计算 1 + 2"}, timeout=60 ) print(resp.json())批量任务方面,核心思路是把一批 prompt 逐条交给runAgent,同时控制并发数量。假设有一个tasks.txt,每行一个任务:
现在几点了? 请计算 1 + 2 请计算 100 + 200可以写一个batch.ts:
import { readFile } from "fs/promises"; import { runAgent } from "./agent"; async function main() { const lines = (await readFile("tasks.txt", "utf-8")).split("\n").filter(Boolean); const concurrency = 3; let index = 0; async function worker() { while (index < lines.length) { const task = lines[index++]; try { const result = await runAgent(task); console.log(`[${task}] => ${result}`); } catch (err) { console.error(`[${task}] => 失败: ${(err as Error).message}`); } } } await Promise.all(Array.from({ length: concurrency }, worker)); } main();批量任务最需要注意的是限流。大模型接口通常有每分钟请求次数限制,并发过高会触发 429。这里用concurrency控制同时进行的任务数,实际要根据接口限制调整。建议批量任务增加重试机制,对超时和 429 做指数退避。
7. 资源占用与性能观察
纯代码层面的资源占用非常低。开启一个 Node.js 进程,运行一个简单 Agent 任务,内存占用通常在几十 MB 级别,具体数值取决于运行时和系统环境。重点要观察的是模型接口的响应时间,因为一次任务可能需要多轮模型调用,每轮几百毫秒到几十秒都有可能。
如果接入的是云端模型,性能瓶颈主要在网络和模型推理延迟上。通过观察日志里每轮callLLM的耗时可定位瓶颈。如果接入的是本地模型,性能瓶颈可能在 GPU 显存或 CPU 算力。此时可以通过nvidia-smi或系统任务管理器观察显存占用。不同模型、不同量化方式、不同并发数量,显存占用差异很大,没有统一答案,以本机实际测试为准。
批量任务要格外注意并发对资源的影响。上面batch.ts虽然限制了并发数,但如果每个任务内部有多轮工具调用,实际同时进行的大模型请求可能超过concurrency。建议在封装批量执行器时统计“当前正在执行的 Agent 数量”,而不只是任务行数。另外,Node.js 默认堆内存有限,如果一次性读取超大tasks.txt或者积累太多消息历史,可以用node --max-old-space-size=4096提升堆内存上限。
8. 常见问题与排查方法
| 问题现象 | 可能原因 | 排查方式 | 解决方案 |
|---|---|---|---|
启动后立刻报Cannot find module | 依赖未安装或 tsx 不存在 | 检查node_modules是否存在 | 运行npm install |
| 请求模型接口返回 401 | API Key 错误或未配置 | 打印.env中读取到的值 | 检查环境变量并确认 Key 有效 |
| 请求模型接口返回 400 | 模型不支持tools或消息格式不对 | 打印请求 body 和响应体 | 换用支持工具调用的模型,或调整消息结构 |
| 模型返回文本,但没有执行工具 | 工具描述不清晰或模型判断不需要调用 | 查看模型返回的完整内容 | 优化工具描述或更换提示词 |
| 工具执行时报 JSON 解析失败 | 模型生成的参数不是合法 JSON | 打印toolCall.function.arguments | 在解析前做 try/catch 并回传错误给模型 |
| 多步循环不结束 | 模型反复调用工具或任务过于复杂 | 检查maxSteps是否过小 | 增加maxSteps或给模型更明确的目标 |
| 批量任务卡住 | 接口限流、超时未处理 | 检查日志中是否有 429 或超时 | 降低并发并增加重试 |
| 本地模型接口能访问但工具调用无效 | 本地服务不支持 function calling 协议 | 查看本地服务文档 | 换用兼容 OpenAItools参数的服务端 |
排查时有一个通用技巧:把callLLM返回的原始 JSON 打印出来看。接口返回里包含了模型决定调用哪个工具、参数是什么、最终回答是什么,任何异常都能从原始结构里找到线索。不要把data.choices[0].message之外的内容忽略掉,很多错误信息在响应体的error字段里。
9. 最佳实践与使用建议
第一次运行先小参数测试。比如先用maxSteps=3,只测试一个工具,确认调用闭环保通后再扩展多个工具。这样能避免问题叠在一起很难定位。建议保留一套最小可运行配置,哪怕后续加了复杂的工具链,也能快速回退到基础版本做排查。
项目目录建议按职责分开:agent.ts放主循环和模型调用,tools.ts放工具定义,types.ts放消息和工具类型。这样后续添加新工具时不需要修改主循环代码,只要往tools数组里加一项即可。工具执行函数要统一返回字符串,方便模型消费。如果工具返回的是对象,提前JSON.stringify再回传,避免消息结构不统一。
安全性是智能体项目最容易忽略的部分。示例里的两个工具没有副作用,但真实系统里工具可能涉及文件读写、数据库操作、外部请求。建议给每个工具增加超时机制,防止模型调用一个永远不返回的工具;同时限制工具可操作的范围,比如指定可读目录、可调用的接口白名单。涉及用户隐私或版权内容时,必须先确认授权范围。
接口服务不要裸奔。如果需要把/agent接口开放给其他系统,建议在前面加一层鉴权,比如 API Token 或签名校验。批量任务要记录日志和失败重试,不能只把结果打印到控制台。日志里至少要包含任务 ID、模型名称、请求耗时、工具调用次数、最终结果,这些信息在排查问题时非常有用。
关于模型选择,建议先用一个小模型跑通流程,再根据效果换成更强模型。小模型速度快、成本低,适合调试;大模型对复杂工具调用的准确率更高,但在工具定义较多时延迟也会增加。对于生产环境,还需要关注模型版本升级对tools行为的影响,最好在发布前做一轮回归测试。
10. 总结与下一步
这个项目最值得尝试的点,是用很小的代码量把 Agent 的工具调用闭环讲清楚了。它没有隐藏逻辑,没有黑盒框架,所有消息流转都在runAgent函数里,非常适合作为智能体开发的入门模板。你先应该验证的功能是“模型返回工具调用 -> 代码执行 -> 结果回传 -> 模型最终回答”这条链路,只要这条路通了,其他功能都可以往上加。
最容易踩的坑有两个:一是模型不支持tools参数,二是消息历史里漏掉tool_call_id关联。前者会导致请求 400,后者会导致模型无法理解工具调用的上下文。只要把这两个点处理好,这个智能体就跑得起来。
后续可以扩展的方向很多:加入短期记忆模块,让 Agent 记住历史对话;接入向量数据库做 RAG,让它能回答私有知识库问题;增加多工具链和任务规划,让 Agent 自动拆解复杂任务;或者把 HTTP 接口从简易示例升级成带鉴权、限流、任务队列的生产服务。从这 100 行代码出发,一条完整的智能体开发路径已经很清晰了。