从零用 TypeScript 实现最小通用智能体:100 行核心循环 📅 发布时间:2026/9/9 1:38:22 👁 浏览次数: 我见过不少人第一次接触通用智能体的时候第一反应是去打开一个成熟的 Agent 框架安装依赖、配置模型、注册工具、读文档然后在“这个东西到底怎么搭”里消耗掉一整个下午。后来我在一个周末做了一次减法不引框架不用复杂配置只用 TypeScript 从零写一个最小可运行的通用智能体。写完发现真正绕不过去的核心逻辑大概就是 100 行代码。这个结论听起来有些反直觉但拆开看并不奇怪。所谓“通用智能体”核心不是某个高级功能而是一个循环模型读取当前的消息历史决定下一步是直接给出回答还是调用某个工具如果调用工具就把工具结果写回消息历史然后继续循环直到模型认为任务已经完成。这篇文章我会先把这套核心循环从零写出来再解释它为什么能被称为“通用”最后补上实际落地时最容易被忽略的边界和工程化问题。1. 先想清楚一件反直觉的事Agent 的核心不是框架是一个循环1.1 我在框架里迷路之后决定自己写一个最小版本有一段时间我研究 Agent 方案打开任何一个框架的文档都会看到一大堆概念记忆、规划、工具、插件、多 Agent 协作、可视化编排。这些能力听起来都很有用但真的要把一个业务接入进去时最常遇到的问题是我该先配什么为什么任务没按预期调用工具为什么模型一轮就停了很多框架的问题不是不好而是太重。面向复杂生产场景设计的抽象层对想理解 Agent 本质的人来说是干扰项。于是我决定不依赖框架用 TypeScript 从零写一个最小版本。目标只有一个把一个能“根据任务自己决定调用什么工具、然后继续完成推理”的循环跑起来。最后我留下的东西远比想象中少一个消息数组、一个工具注册表、一个模型调用函数再加上一层循环控制。这就是整个智能体最核心的样子。1.2 所谓“通用智能体”本质就是一次一次循环决策如果把一个 Agent 任务放慢来看它做的事情非常像人类处理陌生任务的方式先理解当前任务。看看自己手上有什么信息缺什么信息。如果缺信息就去查资料、调接口、执行动作。拿到结果后重新理解任务。如果信息够了给出最终答案如果不够继续查。这个流程不依赖具体业务。无论是查天气、算数学、生成报告还是操作数据库抽象的决策过程是一样的。Agent 要做的“通用”就是这个决策循环的通用而不是内置了所有业务逻辑的通用。所以代码层面不需要为每一种任务单独写状态机。只需要负责一件事把“模型决策”和“工具执行”不断连接起来直到模型判断可以收尾。1.3 为什么这个循环只要 100 行代码就能撑起来因为通用性不在业务代码里而在流程骨架里。在这个循环里每一类具体能力都被包装成一个Tool对象模型看描述决定要不要用每一段上下文都被塞进一个messages数组模型每次决策前都能看到之前发生了什么而“判断下一步做什么”这件事由大模型完成代码本身不需要懂业务。代码要做的只是非常机械的四件事把消息发给模型。拿到模型返回的决策。如果决策是调用工具就解析参数并执行。把工具结果拼回消息历史继续下一轮。只要这四件事被清晰实现任何任务都能在这个循环上跑起来。这也是为什么核心代码可以控制在 100 行左右真正具体的部分都被推迟到了工具函数和模型能力里。2. 用 100 行 TypeScript 把核心循环写出来2.1 设计目标只保留非它不可的部分我给自己定的标准是不引入任何 Agent 框架不使用装饰器不搞复杂依赖注入。核心代码只做调度其他外部能力都通过函数注入。需要保留的部分有三个消息历史承载模型记忆是 Agent 判断下一步的依据。工具注册表告诉模型“有什么能力可用”并在模型决定调用时执行对应函数。模型接口封装大模型的输入输出具体厂商不做绑定。最终的runAgent函数输入用户任务、系统提示词、一批工具和一个模型函数输出最终回答、执行步数和完整消息历史。2.2 核心类型消息、工具、模型返回在写循环之前先把类型定义清楚。TypeScript 在这里的真正价值不是让你多写几个 interface而是把 Agent 里最容易出错的数据流约束住。type Role system | user | assistant | tool; interface Message { role: Role; content: string; tool_call_id?: string; name?: string; } interface ToolCall { id: string; function: { name: string; arguments: string; }; } interface AssistantMessage { role: assistant; content: string | null; tool_calls?: ToolCall[]; } type LLMFunction (messages: Message[]) PromiseAssistantMessage; interface ToolParameterSchema { type: object; properties: Recordstring, unknown; required?: string[]; } interface Tool { name: string; description: string; parameters: ToolParameterSchema; execute: (args: Recordstring, unknown) Promisestring; } interface RunAgentOptions { llm: LLMFunction; tools: Tool[]; systemPrompt: string; userTask: string; maxSteps?: number; verbose?: boolean; }这里最容易被忽略的是tool_call_id。模型返回一个工具调用请求时会带一个调用 ID工具执行完后结果必须以这个 ID 绑定回对应的调用。如果没有配对正确模型会无法理解“这个结果到底对应哪个调用”继而出现幻觉或反复调用。2.3 核心循环代码与逐段解释下面是runAgent的实现。这段代码是 Agent 的最核心骨架类型定义和循环体加在一起控制在 100 行左右。async function runAgent(options: RunAgentOptions) { const maxSteps options.maxSteps ?? 8; const toolMap options.tools.reduceRecordstring, Tool((acc, tool) { acc[tool.name] tool; return acc; }, {}); const messages: Message[] [ { role: system, content: options.systemPrompt }, { role: user, content: options.userTask }, ]; for (let step 0; step maxSteps; step) { const reply await options.llm(messages); messages.push({ role: assistant, content: reply.content ?? }); if (options.verbose) { console.log(\n[Step ${step 1}]); console.log(reply.content ?? (no content, calling tools)); } if (!reply.tool_calls || reply.tool_calls.length 0) { return { finalAnswer: reply.content ?? , steps: step 1, messages }; } for (const call of reply.tool_calls) { const tool toolMap[call.function.name]; if (!tool) { messages.push({ role: tool, content: Unknown tool: ${call.function.name}, tool_call_id: call.id, name: call.function.name, }); continue; } let args: Recordstring, unknown {}; try { args JSON.parse(call.function.arguments || {}); } catch { args {}; } const result await tool.execute(args); if (options.verbose) { console.log([Tool] ${tool.name} - ${result.slice(0, 200)}); } messages.push({ role: tool, content: result, tool_call_id: call.id, name: tool.name, }); } } return { finalAnswer: Reached maxSteps, steps: maxSteps, messages }; }循环体内的关键逻辑只有四段调用模型每次把完整messages交给模型函数。追加助手消息模型返回的文本或工具调用意图必须写回消息历史否则模型下一轮就看不到自己上一轮说了什么。判断是否需要调用工具如果没有tool_calls说明模型已经给出最终答案循环终止。执行工具并回传结果解析参数、执行tool.execute、把结果作为role: tool的消息追加到历史。这段代码的精髓在于循环本身不关心任务内容。工具是否存在、模型如何选择、参数怎么解析都被隔离开。2.4 怎么接真实模型先写一个 LLM 适配函数为了让上面的循环真正跑起来需要一个符合LLMFunction类型的函数。真实场景里这个函数通常会调用某个大模型的接口。以下是一个常见的接入结构具体的 endpoint、模型名和鉴权方式要以你使用的模型服务为准const llm: LLMFunction async (messages) { const resp await fetch(https://your-llm-endpoint.example/v1/chat/completions, { method: POST, headers: { content-type: application/json, authorization: Bearer ${process.env.API_KEY}, }, body: JSON.stringify({ model: your-model-name, messages, }), }); const data await resp.json(); return data.choices[0].message as AssistantMessage; };在学习和演示阶段也可以先写一个模拟模型函数不请求任何外部接口只验证循环逻辑是否正确async function demoLLM(messages: Message[]): PromiseAssistantMessage { const last [...messages].reverse().find((m) m.role user || m.role tool); if (last?.content.includes(天气)) { return { role: assistant, content: null, tool_calls: [ { id: call_demo, function: { name: get_weather, arguments: JSON.stringify({ city: 上海 }), }, }, ], }; } return { role: assistant, content: 今天上海晴26 摄氏度。 }; } async function main() { const result await runAgent({ llm: demoLLM, tools: [weatherTool], systemPrompt: 你是一个能调用工具的助手。, userTask: 上海今天天气怎么样, maxSteps: 5, verbose: true, }); console.log(result.finalAnswer); }这样即使不申请任何模型服务也能先把 Agent 的调度链路跑通。3. 这套结构为什么能被称为“通用”3.1 通用性来自消息历史而不是内置业务很多新手会困惑这套循环没有为任何具体任务写逻辑怎么处理得了复杂的业务答案在于消息历史的累积能力。模型每一轮都能看到完整上下文最初的用户任务、自己刚说过的内容、工具返回的结果。它不需要代码告诉它“现在该做什么”它自己会根据上下文判断。你给它一个天气工具它就会判断需要查天气再回答你给它一个数据库查询工具它就会判断需要先查库再回答。这个设计很像人处理问题的过程。你不需要在每一步写死而是不断获取信息再基于新信息重新判断。消息历史就是 Agent 的“临时工作记忆”所有工具执行结果最终都回到这里。只要这个记忆机制可靠Agent 就能适应不同任务。3.2 工具注册表决定了 Agent 的能力边界在这个循环里工具不参与决策逻辑只负责被调用。每个工具通过三个信息被模型理解name工具名称。description工具用途描述。parameters参数结构。模型会阅读这些信息然后判断“当前任务需不需要这个工具、如果需要就生成对应参数”。这意味着新增一种能力不需要修改循环代码只要往tools数组里增加一个对象即可。假设你要让 Agent 能搜文档就注册一个search_docs工具要让它能发邮件就注册一个send_email工具。模型会自动把这些工具纳入决策范围。这种可扩展性就是“通用”的关键来源之一。3.3 模型是决策者工具只是执行者这套结构把职责分得很清晰模型负责理解任务、拆解步骤、决定调用哪个工具、判断何时可以结束。工具负责执行具体动作返回结构化结果。循环负责把两者连接起来。这种分离带来的直接好处是任何一侧升级都不会影响另一侧。更换更强的模型Agent 的规划能力会变强新增更好的工具Agent 的实操能力会变强循环代码基本不用动。3.4 边界通用不等于无所不能这里必须说清楚“通用”指流程通用不代表结果全对。模型的决策质量决定上限。如果模型本身理解能力弱或者上下文信息不足循环跑得多漂亮都没用。工具返回结果太粗糙模型也容易基于错误信息继续推理。所以这套骨架解决的是“能跑起来”的问题后续效果好不好还要看模型选择、工具设计和上下文管理。4. 最容易翻车的地方不是模型而是循环的退出条件4.1 正常退出和强制退出循环有两个退出出口正常退出模型返回的assistant消息中不再包含tool_calls。强制退出达到maxSteps上限。maxSteps是安全阀。真实场景里模型没有你想象中那么可靠它可能把同一个工具调三遍可能在结果已经足够时依然不结束。没有这个上限一次任务可以烧掉大量 token 和接口配额。我建议起步阶段把maxSteps设在 5 到 10 之间。跑通之后再根据任务复杂度调整。4.2 模型反复调用同一个工具通常说明什么高频问题不是“模型不调用工具”而是“模型反复调用同一个工具”。比如让它查天气它查了一次工具返回了“上海晴 26 度”。按理说信息已经足够但它接着又调用一次get_weather再来一次直到maxSteps耗尽。这时候大多数人会怀疑模型出问题了但问题往往出在信息链路上。常见原因有三个工具返回内容中没有让模型识别出“任务已完成”的信号。系统提示词没有明确告诉模型“拿到结果后直接回答不需要重复调用”。工具结果本身不可用模型觉得信息没拿全只能继续调用。换句话说模型不结束不是模型“笨”而是它在当前信息里没有找到足够的证据去收尾。4.3 退出策略的几种工程化修正针对重复调用可以从几个方向修正在工具返回里补充状态返回文本中可以加上“查询完成”“结果已返回”这类明确标记。在系统提示词中约束行为例如要求“每个工具最多调用一次拿到结果后必须给出最终回答”。在循环里做重复检测记录同一个工具被调用的次数超过阈值直接终止并把目前已有信息交给模型收尾。对工具返回做摘要如果工具返回内容过长模型可能抓不住重点。可以只保留关键片段。这里没有银弹但有一条核心原则退出条件不能只依赖模型的自觉工程上必须有兜底。5. 从 Demo 到真实项目还差这几块拼图5.1 日志让每一步决策都能被复盘100 行循环跑通后第一件要补的事是日志。真实项目里Agent 是一个不稳定的系统。同样的任务模型可能这一步调了工具 A下一步调了工具 B最终回答也可能不一致。如果没有日志出了问题根本无从复盘。建议至少记录每一轮的输入消息数量和大致 token 量。模型返回的原始内容。工具名称、入参、返回结果。每一轮耗时。最终退出原因正常结束还是达到maxSteps。日志格式可以用 JSON Lines一行一条记录方便后续检索和分析。5.2 超时、重试和异常兜底模型接口可能超时工具接口可能暂时不可用工具执行过程也可能抛出异常。核心循环里如果没有兜底任何一个环节抖动都会让整个任务失败。工程上的处理建议是模型调用做超时控制超时后重试一到两次。工具执行包一层try/catch错误信息作为工具结果返回给模型。这样模型至少知道“刚才那个工具失败了”而不是整个流程崩溃。重试只用于幂等操作。如果工具本身有副作用重试前要谨慎确认。5.3 上下文管理长任务最容易踩的坑消息历史会无限增长。用户任务越长、工具返回越多、循环步数越多messages数组就越臃肿。当上下文超过模型窗口时一般有两种处理思路丢弃早期消息只保留最近 N 轮。对工具返回做摘要压缩存储体积。这两种方式都会带来信息损失需要根据任务场景取舍。一个相对稳妥的做法是系统提示词和最新用户输入永远保留中间历史按时间衰减截断工具返回尽量在写入消息历史前先压缩。注意不要等到报错才处理上下文。Agent 任务一旦进入长流程上下文膨胀几乎是必然的提前做好压缩策略会省去很多麻烦。5.4 工具权限不是所有能力都应该暴露给模型工具注册表越丰富Agent 能做的事情越多但风险也越大。如果把“执行 shell 命令”“删除文件”“发送邮件”“支付”这类工具不加限制地注册进去模型一旦理解错用户意图可能造成不可逆后果。建议给工具增加权限分级只读工具模型可自由调用。有副作用工具需要二次确认。高危工具默认不注册只有特定流程才注入。这个设计本质上是把工具注册表做成一个可配置的能力边界而不是把所有能力一次性交给模型。5.5 并行工具调用与资源上限模型一次返回可能包含多个tool_calls。默认实现里我用for循环串行执行这在大多数场景下是安全的。但真实系统里如果不对并行做控制可能一次任务就打出几十个外部接口请求。建议使用简单的并发限制器将同时执行的工具数量限制在 1 到 2 个。这不仅是为了稳定外部依赖也是为了避免一个错误参数导致批量调用失败。下面是一个能力对照表工程能力缺少时的风险落地建议日志无法定位失败原因JSON Lines 记录每步输入输出和耗时超时与重试偶发接口抖动导致任务失败指数退避重试只重试幂等操作上下文压缩长任务 token 超限或模型丢失重点截断早期历史压缩工具返回工具权限模型误调危险工具按只读、有副作用、高危分级并发限制外部接口被瞬时打爆同一时刻最多并发 1 到 2 个工具6. 排查链路从症状定位到根因6.1 先看症状属于哪一类真实使用中Agent 的问题往往不会直接指向代码。先把现象归类能大幅缩短排查时间。现象常见根因完全没有输出LLM 函数没接对、网络失败、鉴权失败、模型返回字段不匹配循环停不下来没有明确退出条件、maxSteps太大、工具返回没有给模型“已完成”信号工具参数总是错参数 schema 描述不清、模型能力不足、缺少示例工具调用报错但 Agent 继续跑execute抛出的异常没有写入消息历史最终回答质量差上下文被截断、工具返回过长、关键信息被忽略6.2 推荐的排查顺序是“消息历史 → 工具返回 → 模型决策 → 代码分支”遇到问题不要一上来改代码先按这个顺序看数据看消息历史把每轮的messages打印出来确认模型能看到的信息是否完整。很多时候问题不在逻辑而在模型没有拿到它需要的内容。看工具返回检查工具结果是否真的有用。返回是不是空字符串是不是格式错误是不是太长被截断看模型决策确认模型是在哪一步决定调用工具的以及它为什么在已有结果后还不结束。看代码分支以上都没问题时再检查循环里的分支逻辑例如tool_calls为空时是否正确返回、参数解析失败时是否有兜底。这个顺序的本质是先确认系统的“输入”和“记忆”没出问题再怀疑代码。6.3 一个高频问题的完整定位过程举一个真实高频场景模型一直在调用get_weather始终不返回最终答案。按上面的排查顺序走一遍看消息历史发现工具已经返回“上海晴26 度”但模型下一轮仍选择调用工具。看工具返回返回文本是“weather in Shanghai: sunny, 26 degree”没有明确的“查询完成”语义。看模型决策模型看到工具返回后可能认为这只是中间信息还需要再确认一次。看代码分支循环本身没问题问题出在工具返回内容和系统提示词上。修复方式很简单工具返回改成“上海当前天气查询完成晴26 度”同时系统提示词增加一句“当天气信息已经返回时直接基于结果回答不要重复调用工具”。问题就解决了。这个例子说明Agent 的问题多数是信息设计问题不是流程代码问题。7. 适用边界这东西到底适合谁7.1 适合学习和验证想法如果你刚接触 Agent不想被框架的抽象淹没我强烈建议先照着这个思路自己实现一遍。不超过 100 行核心代码却能让你对“工具调用、消息历史、循环退出”建立真实体感。以后再去读任何 Agent 框架你会立刻知道它在底层做了什么。7.2 适合给复杂系统当骨架如果你只是要做一个内部自动化工具任务量不大工具数量有限这个骨架完全够用。给它补上日志、权限、重试和上下文压缩后可以承担真实工作负载。7.3 不适合一上来就承担生产级编排如果任务需要多 Agent 协作、复杂状态机、严格事务一致性、版本化工具管理、可视化编排那这个 100 行方案不能直接当生产框架用。核心循环只是骨架规模化之后还需要大量工程能力补充。更应该警惕的是不要以为 Agent 能自动解决所有问题。它依然依赖模型能力、工具质量和上下文管理。核心循环跑通只是起点。7.4 我的建议先跑通循环再谈工程化如果你正在准备自己的第一个 Agent我建议按这个顺序来先用模拟 LLM 函数把循环跑通。接入一个真实模型做一个简单工具比如查天气或查时间。观察循环日志理解模型如何决策。再逐步补日志、重试、上下文压缩和权限控制。先把最小流程跑起来比一开始就设计一个庞大架构重要得多。你会发现真正的复杂度不是在循环里而是在模型输出质量、工具稳定性和业务接入边界上。把这些想清楚比换个更重的框架管用。100 行代码当然不是终点。但它能帮你快速越过“不知道怎么下手”的关卡看到一个通用智能体最本来的样子。