这是《Agent全栈开发》的第 2 篇。上一篇我们搞清楚了 harness 是什么、由哪六大器官拼成。这一篇我们抛开 catbuddy 的全部复杂度,从零手写一个能调工具的最小 Agent Loop——大约 50 行。等你亲手让这颗心脏跳起来,再回头看 catbuddy 那上千行的循环,就知道它到底在这 50 行之上加了什么。
别被「上千行」吓到
很多人看 Agent 框架的源码,第一反应是「这也太复杂了」。catbuddy 的核心循环加上各种治理逻辑确实上千行。但我想告诉你一个让人安心的事实:
Agent Loop 的内核,真的只有十几行。 剩下那些行,全是在处理「理论上不该发生、实际上天天发生」的边界情况。
所以这一篇,我们先把那十几行的内核搭出来,让它真的能跑、能连续调工具。理解了内核,后面所有的「治理逻辑」你才知道是加在哪、为什么加。
我们的目标:写一个 Agent,你跟它说「看看当前目录有哪些文件,然后读一下 package.json 告诉我项目叫什么名字」,它能自己列目录、自己读文件、自己回答。
-
第一步:能调工具的循环骨架
先把骨架立起来。一个 Agent Loop 的内核就是——反复问模型,模型要工具就给它执行,直到它不要了:
async function runAgent(userInput: string) {
const messages = [{ role: "user", content: userInput }];
while (true) {
// ① 带着工具说明,问模型下一步干嘛
const res = await llm.chat({ messages, tools: TOOLS });
messages.push(res.message);
// ② 模型不再要工具 → 它觉得活干完了,退出
if (!res.toolCalls?.length) {
return res.message.content;
}
// ③ 模型要工具 → 逐个执行,把结果喂回去
for (const call of res.toolCalls) {
const result = await runTool(call.name, call.args);
messages.push({ role: "tool", tool_call_id: call.id, content: result });
}
// 回到 ①,带着新结果再问一次
}
}
就这么点。核心就是那个 while:问 → 执行 → 把结果喂回去 → 再问。模型每次都能看到上一步的执行结果,于是它能基于结果决定下一步。这就是 Agent 会「自己推进」的全部秘密。
画成图,这颗心脏的跳动节律是这样的:
-
第二步:给它两把工具
光有循环还不行,模型得有工具可调。工具的本质是两样东西:一份给模型看的说明书(它据此决定要不要调、怎么传参),和一段真正执行的代码。
先写说明书(用 JSON Schema 描述参数,这是 function calling 的标准格式):
const TOOLS = [
{
name: "list_dir",
description: "列出指定目录下的文件",
parameters: {
type: "object",
properties: { path: { type: "string", description: "目录路径" } },
required: ["path"],
},
},
{
name: "read_file",
description: "读取一个文件的内容",
parameters: {
type: "object",
properties: { path: { type: "string" } },
required: ["path"],
},
},
];
再写真正干活的执行器——一个简单的分发:
import { readFile, readdir } from "node:fs/promises";
async function runTool(name: string, args: any): Promise<string> {
switch (name) {
case "list_dir":
return (await readdir(args.path)).join("\n");
case "read_file":
return await readFile(args.path, "utf-8");
default:
return `未知工具: ${name}`;
}
}
注意 runTool 返回的是字符串——因为要塞回 messages 喂给模型,而模型只认文本。工具干的是真事(读真实文件系统),但产出必须翻译成模型能读的文字。
-
跑起来,看它自己推进
把上面三段拼起来,调用一下:
const answer = await runAgent(
"看看当前目录有哪些文件,然后读 package.json 告诉我项目叫什么"
);
console.log(answer);
如果你真的跑它(接上任意一家支持 function calling 的模型),控制台的行为会是这样的——注意它连续转了三轮,没人在中间插手:
[轮1] 模型: 我先看看目录 → 调用 list_dir({path: "."})
执行结果: package.json\nsrc\nREADME.md ...
[轮2] 模型: 有 package.json,读它 → 调用 read_file({path: "package.json"})
执行结果: { "name": "my-app", "version": "1.0.0", ... }
[轮3] 模型: (不再要工具) "这个项目叫 my-app。"
看到没?你只说了一句话,它自己拆成了三步、自己执行、自己得出结论。这就是一个 Agent 了。 不到 50 行,麻雀虽小五脏俱全:有循环(心脏)、有工具(手脚)、有把结果喂回去的上下文拼接(眼睛的雏形)。
如果你之前觉得 Agent 很神秘,到这里应该祛魅了——它没有魔法,就是一个「问-做-再问」的循环。
-
那 catbuddy 的上千行,到底在加什么?
现在最有价值的问题来了:既然 50 行就能跑,为什么真实的 harness 要上千行?
因为我们这 50 行,是一个温室里的玩具。它的每一个假设,在真实世界里都会被打破。我把缺口列出来,你会发现——这张缺口清单,恰好就是本系列后面每一篇:
逐条说:
while(true)永不停:万一模型陷入「读文件→还想读→再读」的怪圈,这循环就停不下来了。生产级必须有最大轮数和用户随时能中断的能力。→ 第 03 篇。runTool太天真:它直接readFile(args.path)。万一模型传path: "/etc/passwd"或者"../../../"呢?万一它要调exec("rm -rf /")呢?真实工具系统的一大半代码都在做安全边界。→ 第 04、05 篇。messages无限膨胀:每轮都往里塞东西,聊几十轮就超过模型的上下文窗口了,再塞就报错。得有人精确算 token、该压缩压缩、该截断截断。→ 第 06 篇。- 进程一关就失忆:
messages是内存里的数组,程序一退就没了。得把对话落到磁盘,还要能把零散对话提炼成 长期记忆。→ 第 07 篇。 llm.chat会失败:网络会抖、API 会超时、模型会返回空、会因为 max_tokens 被截断到一半、你配的主模型会挂。任何一个没兜住,循环就崩了。→ 第 08 篇。
catbuddy 那上千行,全是在把这 50 行从「温室玩具」加固成「能扛真实世界的产品」。 这也是为什么我说,理解了这 50 行的内核,你就有了读懂整个 harness 的地基。
-
一个小提醒:别过早抽象
最后插一句vibe coding catbuddy项目的经验。别一上来就想把这个loop 抽象得很漂亮——搞一堆接口、策略模式、插件机制。
我的建议是:先把这 50 行跑通,亲眼看到它连续调用工具,再去加东西。
因为你会发现,真正的复杂度不在「怎么组织代码」,而在「怎么处理那五类缺口」。抽象是为了管理这些缺口而生的,脱离了缺口去设计抽象,十有八九会设计错。
catbuddy 把循环拆成「外层状态机 + 内层 Runner」两层,不是一开始就这么定的,而是被真实需求逼出来的——这个故事,下一篇细讲。
这篇讲了什么?
- Agent Loop 的内核只有十几行:一个
while循环,反复「问模型 → 执行工具 → 把结果喂回去」,直到模型不再要工具。这就是 Agent 会自我推进的全部秘密。 - 工具 = 一份给模型看的说明书(JSON Schema)+ 一段真正执行的代码,执行结果要翻译成文本喂回去。
- 这 50 行是温室玩具,它的五个天真假设——不会停、太信任、会膨胀、会失忆、会崩——恰好对应后面 03~08 篇。catbuddy 的上千行全是在加固这五处。
下一篇预告:我们的玩具用一个 while 搞定一切,但 catbuddy 用了两层循环——外层状态机管请求生命周期,内层 Runner 管 LLM-工具拉扯。为什么一层不够?多出来的那层解决了什么一层循环死活解决不了的问题?还有,用户点「停止」时怎么让它立刻生效?下一篇见。