news 2026/9/9 1:38:20

从零用 TypeScript 实现最小通用智能体:100 行核心循环

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
从零用 TypeScript 实现最小通用智能体:100 行核心循环

我见过不少人第一次接触通用智能体的时候,第一反应是去打开一个成熟的 Agent 框架:安装依赖、配置模型、注册工具、读文档,然后在“这个东西到底怎么搭”里消耗掉一整个下午。后来我在一个周末做了一次减法:不引框架,不用复杂配置,只用 TypeScript 从零写一个最小可运行的通用智能体。写完发现,真正绕不过去的核心逻辑,大概就是 100 行代码。

这个结论听起来有些反直觉,但拆开看并不奇怪。所谓“通用智能体”,核心不是某个高级功能,而是一个循环:模型读取当前的消息历史,决定下一步是直接给出回答,还是调用某个工具;如果调用工具,就把工具结果写回消息历史,然后继续循环,直到模型认为任务已经完成。

这篇文章我会先把这套核心循环从零写出来,再解释它为什么能被称为“通用”,最后补上实际落地时最容易被忽略的边界和工程化问题。

1. 先想清楚一件反直觉的事:Agent 的核心不是框架,是一个循环

1.1 我在框架里迷路之后,决定自己写一个最小版本

有一段时间我研究 Agent 方案,打开任何一个框架的文档,都会看到一大堆概念:记忆、规划、工具、插件、多 Agent 协作、可视化编排。这些能力听起来都很有用,但真的要把一个业务接入进去时,最常遇到的问题是:我该先配什么?为什么任务没按预期调用工具?为什么模型一轮就停了?

很多框架的问题不是不好,而是太重。面向复杂生产场景设计的抽象层,对想理解 Agent 本质的人来说是干扰项。于是我决定不依赖框架,用 TypeScript 从零写一个最小版本。目标只有一个:把一个能“根据任务自己决定调用什么工具、然后继续完成推理”的循环跑起来。

最后我留下的东西远比想象中少:一个消息数组、一个工具注册表、一个模型调用函数,再加上一层循环控制。这就是整个智能体最核心的样子。

1.2 所谓“通用智能体”,本质就是一次一次循环决策

如果把一个 Agent 任务放慢来看,它做的事情非常像人类处理陌生任务的方式:

  1. 先理解当前任务。
  2. 看看自己手上有什么信息,缺什么信息。
  3. 如果缺信息,就去查资料、调接口、执行动作。
  4. 拿到结果后,重新理解任务。
  5. 如果信息够了,给出最终答案;如果不够,继续查。

这个流程不依赖具体业务。无论是查天气、算数学、生成报告还是操作数据库,抽象的决策过程是一样的。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[]) => Promise<AssistantMessage>; interface ToolParameterSchema { type: "object"; properties: Record<string, unknown>; required?: string[]; } interface Tool { name: string; description: string; parameters: ToolParameterSchema; execute: (args: Record<string, unknown>) => Promise<string>; } 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.reduce<Record<string, 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: Record<string, 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 }; }

循环体内的关键逻辑只有四段:

  1. 调用模型:每次把完整messages交给模型函数。
  2. 追加助手消息:模型返回的文本或工具调用意图,必须写回消息历史,否则模型下一轮就看不到自己上一轮说了什么。
  3. 判断是否需要调用工具:如果没有tool_calls,说明模型已经给出最终答案,循环终止。
  4. 执行工具并回传结果:解析参数、执行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[]): Promise<AssistantMessage> { 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耗尽。这时候大多数人会怀疑模型出问题了,但问题往往出在信息链路上。

常见原因有三个:

  1. 工具返回内容中,没有让模型识别出“任务已完成”的信号。
  2. 系统提示词没有明确告诉模型“拿到结果后直接回答,不需要重复调用”。
  3. 工具结果本身不可用,模型觉得信息没拿全,只能继续调用。

换句话说,模型不结束,不是模型“笨”,而是它在当前信息里没有找到足够的证据去收尾。

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 推荐的排查顺序是“消息历史 → 工具返回 → 模型决策 → 代码分支”

遇到问题不要一上来改代码,先按这个顺序看数据:

  1. 看消息历史:把每轮的messages打印出来,确认模型能看到的信息是否完整。很多时候问题不在逻辑,而在模型没有拿到它需要的内容。
  2. 看工具返回:检查工具结果是否真的有用。返回是不是空字符串?是不是格式错误?是不是太长被截断?
  3. 看模型决策:确认模型是在哪一步决定调用工具的,以及它为什么在已有结果后还不结束。
  4. 看代码分支:以上都没问题时,再检查循环里的分支逻辑,例如tool_calls为空时是否正确返回、参数解析失败时是否有兜底。

这个顺序的本质,是先确认系统的“输入”和“记忆”没出问题,再怀疑代码。

6.3 一个高频问题的完整定位过程

举一个真实高频场景:模型一直在调用get_weather,始终不返回最终答案。

按上面的排查顺序走一遍:

  1. 看消息历史:发现工具已经返回“上海晴,26 度”,但模型下一轮仍选择调用工具。
  2. 看工具返回:返回文本是“weather in Shanghai: sunny, 26 degree”,没有明确的“查询完成”语义。
  3. 看模型决策:模型看到工具返回后,可能认为这只是中间信息,还需要再确认一次。
  4. 看代码分支:循环本身没问题,问题出在工具返回内容和系统提示词上。

修复方式很简单:工具返回改成“上海当前天气查询完成:晴,26 度”;同时系统提示词增加一句“当天气信息已经返回时,直接基于结果回答,不要重复调用工具”。问题就解决了。

这个例子说明,Agent 的问题多数是信息设计问题,不是流程代码问题。

7. 适用边界:这东西到底适合谁

7.1 适合学习和验证想法

如果你刚接触 Agent,不想被框架的抽象淹没,我强烈建议先照着这个思路自己实现一遍。不超过 100 行核心代码,却能让你对“工具调用、消息历史、循环退出”建立真实体感。以后再去读任何 Agent 框架,你会立刻知道它在底层做了什么。

7.2 适合给复杂系统当骨架

如果你只是要做一个内部自动化工具,任务量不大,工具数量有限,这个骨架完全够用。给它补上日志、权限、重试和上下文压缩后,可以承担真实工作负载。

7.3 不适合一上来就承担生产级编排

如果任务需要多 Agent 协作、复杂状态机、严格事务一致性、版本化工具管理、可视化编排,那这个 100 行方案不能直接当生产框架用。核心循环只是骨架,规模化之后还需要大量工程能力补充。

更应该警惕的是:不要以为 Agent 能自动解决所有问题。它依然依赖模型能力、工具质量和上下文管理。核心循环跑通只是起点。

7.4 我的建议:先跑通循环,再谈工程化

如果你正在准备自己的第一个 Agent,我建议按这个顺序来:

  1. 先用模拟 LLM 函数把循环跑通。
  2. 接入一个真实模型,做一个简单工具,比如查天气或查时间。
  3. 观察循环日志,理解模型如何决策。
  4. 再逐步补日志、重试、上下文压缩和权限控制。

先把最小流程跑起来,比一开始就设计一个庞大架构重要得多。你会发现,真正的复杂度不是在循环里,而是在模型输出质量、工具稳定性和业务接入边界上。把这些想清楚,比换个更重的框架管用。

100 行代码当然不是终点。但它能帮你快速越过“不知道怎么下手”的关卡,看到一个通用智能体最本来的样子。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/9 1:37:45

Python爬虫+数据分析:小说数据采集与可视化课程设计实战拆解

简介&#xff1a;一套基于网络爬虫技术的小说网数据采集、分析与可视化课程设计源码&#xff0c;专为需要完成期末大作业或课程设计的Python初学者打造&#xff0c;也可作为毕业设计前期探索的参考模板。项目完整覆盖爬虫调度、网页解析、数据清洗、结果存储与可视化展示等关键…

作者头像 李华
网站建设 2026/9/9 1:35:45

RS485物理层四大故障与缓存集线器治理方案

1. 工业现场的485通讯&#xff0c;从来不是“接上线就能通”那么简单我第一次在产线调试485设备时&#xff0c;手握万用表、示波器和三台不同品牌的PLC&#xff0c;花了整整两天半——不是因为不会接线&#xff0c;而是因为“明明单点测试全通&#xff0c;一挂上总线就丢包、乱…

作者头像 李华
网站建设 2026/9/9 1:35:11

AI视频总结工具实测:B站长视频一键转图文笔记

1. 场景与需求拆解&#xff1a;为什么我们需要AI视频总结1.1 视频信息爆炸与学习焦虑B站早就不只是追番看鬼畜的地方了。我现在查技术教程、看行业分享、学软件操作&#xff0c;第一反应都是先来B站搜一遍。但问题也随之而来&#xff1a;一个教程动辄二三十分钟&#xff0c;一个…

作者头像 李华
网站建设 2026/9/9 1:32:23

设备改型下西门子PLC选型的五大硬性校验维度

1. 项目概述&#xff1a;设备改型不是“换壳”&#xff0c;而是PLC系统级重构的触发点设备改型后西门子PLC要不要重新选型&#xff1f;这个问题在自动化现场每天都在发生&#xff0c;但90%的工程师第一反应是“看情况”——这恰恰是最危险的信号。我干了13年自动化集成&#xf…

作者头像 李华
网站建设 2026/9/9 1:32:02

硬件电路设计实战:从原理图到PCB调试的完整学习路径

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/9 1:30:40

量化数据API评估指南:从数据质量到链路稳定性的完整实操

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华