02|50 行跑通一个 Agent Loop:harness 的最小内核

这是《Agent全栈开发》的第 2 篇。上一篇我们搞清楚了 harness 是什么、由哪六大器官拼成。这一篇我们抛开 catbuddy 的全部复杂度,从零手写一个能调工具的最小 Agent Loop------大约 50 行。等你亲手让这颗心脏跳起来,再回头看 catbuddy 那上千行的循环,就知道它到底在这 50 行之上加了什么。


别被「上千行」吓到

很多人看 Agent 框架的源码,第一反应是「这也太复杂了」。catbuddy 的核心循环加上各种治理逻辑确实上千行。但我想告诉你一个让人安心的事实:

Agent Loop 的内核,真的只有十几行。 剩下那些行,全是在处理「理论上不该发生、实际上天天发生」的边界情况。

所以这一篇,我们先把那十几行的内核搭出来,让它真的能跑、能连续调工具。理解了内核,后面所有的「治理逻辑」你才知道是加在哪、为什么加。

我们的目标:写一个 Agent,你跟它说「看看当前目录有哪些文件,然后读一下 package.json 告诉我项目叫什么名字」,它能自己列目录、自己读文件、自己回答。


第一步:能调工具的循环骨架

先把骨架立起来。一个 Agent Loop 的内核就是------反复问模型,模型要工具就给它执行,直到它不要了

javascript 复制代码
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 的标准格式):

javascript 复制代码
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"],
    },
  },
];

再写真正干活的执行器------一个简单的分发:

javascript 复制代码
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 喂给模型,而模型只认文本。工具干的是真事(读真实文件系统),但产出必须翻译成模型能读的文字。


跑起来,看它自己推进

把上面三段拼起来,调用一下:

ini 复制代码
const answer = await runAgent(
  "看看当前目录有哪些文件,然后读 package.json 告诉我项目叫什么"
);
console.log(answer);

如果你真的跑它(接上任意一家支持 function calling 的模型),控制台的行为会是这样的------注意它连续转了三轮,没人在中间插手:

javascript 复制代码
[轮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」两层,不是一开始就这么定的,而是被真实需求逼出来的------这个故事,下一篇细讲。


这篇讲了什么?

  1. Agent Loop 的内核只有十几行 :一个 while 循环,反复「问模型 → 执行工具 → 把结果喂回去」,直到模型不再要工具。这就是 Agent 会自我推进的全部秘密。
  2. 工具 = 一份给模型看的说明书 (JSON Schema)+ 一段真正执行的代码,执行结果要翻译成文本喂回去。
  3. 这 50 行是温室玩具,它的五个天真假设------不会停、太信任、会膨胀、会失忆、会崩------恰好对应后面 03~08 篇。catbuddy 的上千行全是在加固这五处。

下一篇预告 :我们的玩具用一个 while 搞定一切,但 catbuddy 用了两层循环------外层状态机管请求生命周期,内层 Runner 管 LLM-工具拉扯。为什么一层不够?多出来的那层解决了什么一层循环死活解决不了的问题?还有,用户点「停止」时怎么让它立刻生效?下一篇见。

相关推荐
小虎AI生活2 小时前
workbuddy 获客自动化,每天让浏览器 Agent 帮你巡场
ai编程
wechatbot8886 小时前
企业微信API开发:登录-联系人查询-消息发送完整开发流程分享
汇编·微信·自动化·企业微信·ai编程·rpa
AI大模型-小华7 小时前
Codex 反复重试仍完不成任务?判断 ChatGPT Plus 是否需要调整到 Pro
人工智能·chatgpt·ai编程·codex·开发效率·chatgpt plus·chatgpt pro
ajassi200010 小时前
AI语音智能体开发日记(三)解决小程序配网中的蓝牙命名与MAC地址获取问题
ai·apache·ai编程
vibecoding7714 小时前
GitHub 2026 年 7 月热榜:累计 Star 总榜与月度飙星榜
人工智能·github·ai编程
aqi0014 小时前
15天学会AI应用开发(十八)使用LangGraph实现精确记忆功能
人工智能·python·大模型·ai编程·ai应用
黑科技iOS上架14 小时前
26B模型Mac 8g内存能跑么?
经验分享·ai编程