我见过不少人第一次接触通用智能体的时候,第一反应是去打开一个成熟的 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[]) => 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 }; }循环体内的关键逻辑只有四段:
- 调用模型:每次把完整
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[]): 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耗尽。这时候大多数人会怀疑模型出问题了,但问题往往出在信息链路上。
常见原因有三个:
- 工具返回内容中,没有让模型识别出“任务已完成”的信号。
- 系统提示词没有明确告诉模型“拿到结果后直接回答,不需要重复调用”。
- 工具结果本身不可用,模型觉得信息没拿全,只能继续调用。
换句话说,模型不结束,不是模型“笨”,而是它在当前信息里没有找到足够的证据去收尾。
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 行代码当然不是终点。但它能帮你快速越过“不知道怎么下手”的关卡,看到一个通用智能体最本来的样子。