原来 AI Agent 的核心循环这么简单:手搓一个 Agent Loop

82 阅读13分钟

image.png

🚀 欢迎来到 不用框架,手搓 AI Agent 专栏第三站。

上一篇咱们给 AI 装上了手 read_file:它终于能先读真实文件,再回答我们的问题了。

但当时的实现还有一个很明显的限制:它只允许调用一次工具。

先来“放个毒”,看看经过本系列的打磨,我们最终会亲手搞出一个怎样的“完全体”

Kapture 2026-07-13 at 10.07.08.gif

先回忆一下上一篇的流程:

用户提问
→ AI 申请读一次文件
→ 程序读取文件
→ AI 根据文件内容回答

这个过程可以用下面这张图来理解:

image.png

这条链路可以跑,但它更像一段提前写死的剧本。

比如你问它:

 "先读 README.md,再看看 package.json,告诉我这个项目怎么启动"

它读完 README.md 后,可能还需要看 package.json。可上一篇的代码会直接让它开始回答,第二次读文件的机会根本没有。

真实的 AI agent不是这么干活的。它会像我们排查问题一样:先看一个文件,发现信息量不够,就再看另一个;直到拿到足够的信息后,才给出结论。

今天这一篇,我们不再替 AI 把步骤写死。我们让它读完一个文件后,可以想一想:现在掌握的信息够不够?不够就继续查,够了再回答。

这个“想一想 → 动手 → 看结果 → 再想一想”的过程,就是大家经常听到的 Agent Loop(Agent 循环)

先看今天要实现的效果

代码写完后,运行同一条命令:

npm run start -- -prompt "先读 README.md,再看看 package.json,告诉我这个项目怎么启动"

终端可能会看到这样的过程:

AI 正在思考...

第 1 轮:AI 想调用 read_file
正在读取文件:README.md

第 2 轮:AI 想调用 read_file
正在读取文件:package.json

AI:这是一个 TypeScript 项目。先安装依赖,再执行 npm run build,最后执行 npm run start ...

这个过程可以用下面这张图来理解:

image.png

这个例子里,AI 第 1 轮先申请读取 README.md。程序读完后,把结果交回给它;AI 看完觉得信息还不够,于是在第 2 轮继续申请读取 package.json。拿到两个文件的内容后,它才给出最终回答。

这里先记住一个小点:一轮不一定只会调用一次工具。

有些模型会像上面这样,分两轮读取两个文件;也有些模型会在第 1 轮就同时申请两次 read_file,一次读 README.md,一次读 package.json。不管是哪种情况,咱们后面写的循环都能处理。

重点不在于它一共读了几个文件,而在于它终于会自己决定下一步:

想一想
→ 调工具
→ 看结果
→ 再想一想
→ 继续调工具,或者回答用户

这个过程可以用下面这张图来理解:

image.png

这就是 Agent Loop(Agent 循环)。

🚀 本节配套源码:powercode

本篇是在第二篇代码的基础上继续修改。如果你还没跑过第二篇,建议先把 read_file 跑通。


先把“循环”想明白

“Agent Loop”这个词听上去很唬人,但把它换成日常话,其实就是:

别替 AI 规定只能干几步;每做完一步,都再问它一次:现在还需要做什么?

你自己查一个陌生项目时,多半也是这个过程:

用户:这个项目怎么启动?

你:先看看 README
你:README 只说了项目用途,不够,再看看 package.json
你:找到了 scripts,可以回答了

这个过程可以用下面这张图来理解:

image.png

AI 也一样。模型本身只负责判断“下一步该做什么”;而我们的程序负责执行它申请的工具,并把结果交还给它。

可以把职责分成两边:

AI:决定下一步
程序:执行这一步,并把结果带回来

这个过程可以用下面这张图来理解:

image.png

只要 AI 还在申请工具,我们就继续循环;只要它开始输出普通文本,说明它觉得信息够了,循环就结束。

上一篇的代码,卡在哪里?

上一篇 src/main.ts 中有这段逻辑:

const firstMessage = await client.askWithTools(prompt);
const toolCall = firstMessage?.tool_calls?.[0];

if (!toolCall) {
  console.log(`AI:${firstMessage?.content ?? '模型没有返回内容。'}`);
  return;
}

const fileContent = await readFileTool(toolCall.function.arguments);
const answer = await client.answerAfterReadFile(
  prompt,
  toolCall,
  fileContent,
);

console.log(`AI:${answer}`);

它把流程固定成了:

第一次请求模型
→ 第一次工具调用
→ 第二次请求模型
→ 结束

这里要分清楚:用户从头到尾只问了一个问题;但程序为了完成这次回答,向模型发了两次请求。

第一次请求,模型决定要不要读文件;读完后,第二次请求把文件内容交给模型,让它组织最终答案。

问题不在 read_file,也不在模型,而在第二次请求时,我们没有再把 tools 工具传给模型,随后又直接打印答案并结束程序。也就是说,模型就算还想再读一个文件,也没有继续调用工具的机会。

所以今天我们不改工具本身,只把“写死的两次请求”,换成“带退出条件的循环”。


第一步:别把聊天记录丢掉

先说一个特别关键的点:循环中的每次请求,都要带上之前发生过的事情。

想象你和同事聊天:

你:帮我看看 README。
同事:好的,我看到 README 了。
你:那 package.json 呢?

这个过程可以用下面这张图来理解:

image.png

如果每说一句都把前面的聊天记录清空,同事就不知道“那”指的是什么。

模型也是一样。每一轮都重新只传用户问题,它就不知道:

  • 刚才已经申请过什么工具;
  • 工具实际返回了什么;
  • 哪些文件已经看过。

因此,我们需要一个 messages 数组,把整段对话一直保存下来。

这就是大家常说的上下文:每次请求模型时,我们把前面发生过的事一起带上,让它知道自己正做到哪一步。

这份 messages 里的内容,叫“消息历史”。你可以先把它理解成 Agent 的短期记忆;后面讲长任务时,我们还会看到更广义的上下文和记忆。

新建 src/agent.ts,先写一个最小骨架:

import type OpenAI from 'openai';
import { readFileTool } from './readFile.js';
import { ChatClient } from './chat.js';

const MAX_STEPS = 8;

export class Agent {
  constructor(private readonly client: ChatClient) {}

  async run(prompt: string): Promise<string> {
    const messages: OpenAI.Chat.Completions.ChatCompletionMessageParam[] = [
      {
        role: 'system',
        content:
          '你是 power-code,一个研发助手。需要了解项目内容时,优先读取真实文件。请使用中文回答。',
      },
      {
        role: 'user',
        content: prompt,
      },
    ];

    // 循环代码接下来写在这里
    return '暂未实现';
  }
}

这里的 messages 和上一篇第一次请求时传的内容,本质上是同一件事。不同在于:现在它不再是一次性写在请求里,而是放进变量中,后面每一轮循环都继续复用、继续追加。

MAX_STEPS 先放在这里,等下会解释它为什么必须存在。

第二步:让 ChatClient 接收完整聊天记录

上一篇的 askWithTools 接收的是一个 prompt 字符串,内部自己拼好消息;answerAfterReadFile 则专门负责“读完一次文件后的第二次请求”。现在消息会越来越多,这两个写死流程都不合适了,拼消息这件事应该交给 Agent 管理。

所以这里不要只改其中一个方法,直接用下面的代码替换整个 src/chat.ts 文件。替换后,原来的 askWithToolsanswerAfterReadFile 都会删除,统一换成新的 complete 方法。

import OpenAI from 'openai';
import type { ProviderConfig } from './config.js';
import { READ_FILE_TOOL } from './readFile.js';

export class ChatClient {
  private readonly client: OpenAI;

  constructor(private readonly config: ProviderConfig) {
    this.client = new OpenAI({
      apiKey: config.apiKey, // 从环境变量中获取 API 密钥
      baseURL: config.baseURL, // 从环境变量中获取 API 基础 URL
    });
  }

  /**
   *  完成对话
   * @param messages 全部聊天记录
   * @returns  模型回复的消息
   */
  async complete(
    messages: OpenAI.Chat.Completions.ChatCompletionMessageParam[],
  ) {
    const response = await this.client.chat.completions.create({
      model: this.config.model,
      messages,
      tools: [READ_FILE_TOOL],
    });

    return response.choices[0]?.message;
  }
}

这段代码干的事很简单:收到“截至现在的全部聊天记录”,原样发给模型,并把模型的最新消息返回。

这里有一个小变化很重要:complete 不关心当前是第几轮,也不关心模型会不会调用工具。它只负责一次模型请求。

这样分工之后会更清楚:

ChatClient:请求模型一次
Agent:决定要不要继续下一轮

第三步:先接住模型的每一句话

回到 src/agent.ts,把刚才的 return '暂未实现' 换成:

for (let step = 1; step <= MAX_STEPS; step += 1) {
  const message = await this.client.complete(messages);

  if (!message) {
    throw new Error('模型没有返回消息。');
  }

  messages.push(message);

  // 接下来判断:它是想调用工具,还是已经准备回答?
}

throw new Error(`执行超过 ${MAX_STEPS} 轮,已停止。`);

每轮做完三件事:

  1. 把当前完整的 messages 发给模型;
  2. 拿到模型新回复;
  3. 立刻把这条新回复也塞回 messages

为什么模型的回复也要保存?因为它可能包含一次工具调用请求。后面我们把工具结果交回模型时,模型必须能对上:这个工具结果,到底是在回答哪一次调用。

到这里,循环已经有了;但它还不知道什么时候停,也不会真的执行工具。


第四步:模型不再要工具时,就结束

模型可能会返回两类消息:

第一类是普通回答:

这个项目可以通过 npm run build 构建,再用 npm run start 启动。

第二类是工具调用:

请调用 read_file,参数是 {"path":"package.json"}

在 OpenAI 兼容接口里,第二类会出现在 message.tool_calls 中。

这里顺便把这个名字讲明白。工具调用也经常叫 Function Calling:模型并不是真的自己去执行了 read_file,而是按接口约定,返回一份结构化的“工具申请单”,告诉我们的程序“请帮我调用哪个工具、参数是什么”。在 Chat Completions 这套接口里,这份申请单就放在 tool_calls 字段中。

这套写法是 OpenAI 接口带火的。现在很多标明“OpenAI 兼容”的模型服务,也会沿用 toolstool_calls 这些字段,所以咱们这套代码通常能直接接上国内模型;不过具体模型是否支持工具调用、支持哪些参数,还是要以它自己的文档为准。

如果模型这轮不需要工具,它的 tool_calls 通常会是空的,或者干脆没有这个字段。因此我们用 ?? [] 把“没有这个字段”也统一当成空数组来处理。

因此,在刚才 messages.push(message) 后面补上:

const toolCalls = message.tool_calls ?? [];

if (toolCalls.length === 0) {
  return message.content ?? '模型没有返回文本内容。';
}

这里判断的不是“模型有没有真的把文件读完”,而是:它这一轮还要不要让我们的程序帮它干活。

读取文件是程序替它读的,模型只能提出申请。

于是逻辑就变成:

没有 tool_calls
→ AI 这轮没有再申请工具
→ 它认为目前拿到的信息已经够用
→ 直接把这轮文字回复给用户,循环结束

有 tool_calls
→ AI 还想让程序替它读文件、改文件或做别的事
→ 程序先执行工具,把结果告诉 AI
→ AI 再根据新结果想下一步

这个过程可以用下面这张图来理解:

image.png

所以,tool_calls 为空代表模型不需要工具了。对咱们这个最小 Agent 来说,这时就把它返回的文字当作最终答案,结束循环。

这就是 Agent Loop 最核心的退出条件。

第五步:执行工具,并把结果塞回消息历史

回到刚才新建的 src/agent.ts。上一步咱们已经在外层的 for 循环里,写好了这段“没有工具就直接返回”的代码:

if (toolCalls.length === 0) {
  return message.content ?? '模型没有返回文本内容。';
}

现在在这段代码后面再粘贴下面的代码

// 处理工具调用
for (const toolCall of toolCalls) {
  if (toolCall.type !== 'function') {
    throw new Error(`暂时不支持工具类型:${toolCall.type}`);
  }

  if (toolCall.function.name !== 'read_file') {
    throw new Error(`暂时不支持工具:${toolCall.function.name}`);
  }

  console.log(`第 ${step} 轮:AI 想调用 read_file`);
  console.log(`正在读取文件:${toolCall.function.arguments}\n`);

  let result: string;

  try {
    result = await readFileTool(toolCall.function.arguments);
  } catch (error) {
    const message = error instanceof Error ? error.message : String(error);
    result = `读取失败:${message}`;
  }

  messages.push({
    role: 'tool',
    tool_call_id: toolCall.id,
    content: result,
  });
}

这段代码稍长,但只是在完整走一遍“申请 → 执行 → 回传”:

模型:我想读 package.json
程序:好的,正在读
程序:读到的内容是……
模型:收到,我继续判断下一步

这个过程可以用下面这张图来理解:

image.png

最容易漏掉的是最后的 messages.push(...)

它不是打印日志,而是把工具的真实结果以 tool 角色放回对话历史。下一轮请求模型时,这份结果就会一起传过去。

另外,我们没有在读取失败时直接让程序崩掉,而是把失败原因也告诉模型:

读取失败:ENOENT,文件不存在

这样模型还有机会换一个路径、或者告诉用户文件不存在。更完整的错误恢复会放到后面的篇章,这里先记住一句:工具失败也是信息,最好让 AI 看见

完整的 src/agent.ts

为了方便你直接运行,下面是完整版本:

import type OpenAI from "openai";
import { readFileTool } from "./readFile.js";
import { ChatClient } from "./chat.js";

// 最大循环次数
const MAX_STEPS = 8;

export class Agent {
  constructor(private readonly client: ChatClient) {}

  async run(prompt: string): Promise<string> {
    const messages: OpenAI.Chat.Completions.ChatCompletionMessageParam[] = [
      {
        role: "system",
        content:
          "你是 power-code,一个研发助手。需要了解项目内容时,优先读取真实文件。请使用中文回答。",
      },
      {
        role: "user",
        content: prompt,
      },
    ];

    for (let step = 1; step <= MAX_STEPS; step += 1) {
      const message = await this.client.complete(messages);

      if (!message) {
        throw new Error("模型没有返回消息。");
      }

      messages.push(message);

      // 接下来判断:它是想调用工具,还是已经准备回答?
      const toolCalls = message.tool_calls ?? [];

      if (toolCalls.length === 0) {
        return message.content ?? "模型没有返回文本内容。";
      }

      // 处理工具调用
      for (const toolCall of toolCalls) {
        if (toolCall.type !== "function") {
          throw new Error(`暂时不支持工具类型:${toolCall.type}`);
        }

        if (toolCall.function.name !== "read_file") {
          throw new Error(`暂时不支持工具:${toolCall.function.name}`);
        }

        console.log(`第 ${step} 轮:AI 想调用 read_file`);
        console.log(`正在读取文件:${toolCall.function.arguments}\n`);

        let result: string;

        try {
          result = await readFileTool(toolCall.function.arguments);
        } catch (error) {
          const message =
            error instanceof Error ? error.message : String(error);
          result = `读取失败:${message}`;
        }

        messages.push({
          role: "tool",
          tool_call_id: toolCall.id,
          content: result,
        });
      }
    }

    throw new Error(`执行超过 ${MAX_STEPS} 轮,已停止。`);
  }
}

这里顺手支持了一个很实用的情况:for (const toolCall of toolCalls)

有些模型会一次申请多个工具,例如同时读 README.mdpackage.json。上一篇只取 [0],后面的调用会被忽略;现在我们会把这一轮请求里的每一个工具都执行完,再进入下一轮。

至于这一轮里出现好几个工具调用时,要不要让它们并发执行,咱们先不急着钻。先让它一个一个按顺序执行,逻辑最直观,也足够完成今天的目标。等后面工具多起来、任务更复杂时,我们再专门聊怎么让它们并发干活。


第六步:让入口只负责启动 Agent

打开 src/main.ts删除里面原来的全部内容,再完整复制下面这份代码进去。不用保留上一章的工具调用处理逻辑。

import { Agent } from './agent.js';
import { ChatClient } from './chat.js';
import { loadConfig } from './config.js';

function getPrompt(args: string[]): string {
  const promptIndex = args.indexOf('-prompt');

  if (promptIndex === -1) {
    throw new Error('请通过 -prompt 传入问题,例如:-prompt "你好"');
  }

  const prompt = args[promptIndex + 1];

  if (!prompt) {
    throw new Error('-prompt 后面不能是空内容。');
  }

  return prompt;
}

async function main() {
  const prompt = getPrompt(process.argv.slice(2));
  const config = await loadConfig();
  const client = new ChatClient(config);
  const agent = new Agent(client);

  console.log('AI 正在思考...\n');

  const answer = await agent.run(prompt);
  console.log(`AI:${answer}`);
}

main().catch((error: unknown) => {
  const message = error instanceof Error ? error.message : String(error);
  console.error(`启动失败:${message}`);
  process.exit(1);
});

现在入口非常干净:拿到用户问题,创建 Agent,运行,打印最终答案。

至于中间要读几次文件、什么时候停止,全都交给 Agent.run

先编译:

npm run build

再试一个明确需要多次读取的问题:

npm run start -- -prompt "先读 README.md,再看看 package.json,告诉我这个项目怎么启动"

如果模型只读了一个文件就直接回答,不一定是代码有问题。模型认为已有信息足够时,本来就可以结束。

你也可以换一个更明确的提问

npm run start -- -prompt "必须分别读取 README.md 和 package.json。读取完后,告诉我项目用途、构建命令和启动命令"

这次重点看两件事:终端有没有真的读取对应文件;以及日志里的“第几轮”到底代表什么。

image.png

以截图里的结果为例,模型在第 1 轮一次申请了两次 read_file:一次读 README.md,一次读 package.json。程序会按顺序把这两个文件读完,并把两个结果都放回 messages

接着程序会再请求一次模型。模型拿到两个文件内容后,直接返回了最终回答,没有再申请工具,于是循环结束。

注意:终端里看起来没有“第 2 轮”的日志,并不代表没有第二次请求模型。我们现在的日志只会在 AI 申请工具时打印;第 2 次请求直接得到文字答案,自然不会打印“AI 想调用 read_file”。


为什么一定要设最大轮数?

既然叫循环,很多人会自然想到:那就一直循环到 AI 回答不就行了?

不行。因为模型并不保证每次都做出最优决定,它可能:

  • 连续读取同一个文件;
  • 一直尝试不存在的路径;
  • 因为提示不清楚,反复收集用不上的信息。

如果没有上限,程序会一直请求模型、一直花 Token,甚至一直跑不完。

所以我们加了:

const MAX_STEPS = 8;

它像给 Agent 配了一个倒计时:八轮以内没完成,就先停下来,告诉我们发生了什么。

这个数字没有绝对标准。学习项目里用 8 比较容易观察;真正项目里可以按任务类型、成本和超时策略来设置。重要的不是具体数字,而是:只要有循环,就必须有明确退出条件和上限。

到这里,我们真正拥有了什么?

现在的 power-code 还只会读文件,但它已经不再是“只能调用一次工具的聊天程序”了。

它有了最核心的一套工作节奏:

用户提出任务
→ 模型判断下一步
→ 程序执行工具
→ 工具结果回到消息历史
→ 模型继续判断
→ 直到模型给出最终答案

这个过程可以用下面这张图来理解:

image.png

后面我们再给它加 write_fileedit_filebash,这个agent循环不用推倒重来。我们只需要让它在“执行工具”时认识更多工具就可以了。

到这里你应该能发现:光有一个聪明的大模型不够,光塞给它一堆工具也不够。

工具解决的是“它能不能动手”;而这个循环解决的是“它动完这一步以后,要不要继续干下一步”。

下一篇预告

现在有一个新问题:如果工具越来越多,难道要在 agent.ts 里不断写:

if (toolCall.function.name === 'read_file') {
  // ...
}

那很快就会乱。

下一篇,咱们就来做一个“工具管理中心”:给每个工具统一定义、统一注册、统一执行。到那时,除了读文件,AI 终于可以开始写文件、精确修改文件和执行命令。

如果这篇跑通了,你就已经摸到了绝大多数 AI Agent 最关键的骨架。后面加再多能力,本质上也都是往这个循环里接工具、加规则。