
🚀 欢迎来到 不用框架,手搓 AI Agent 专栏第三站。
上一篇咱们给 AI 装上了手
read_file:它终于能先读真实文件,再回答我们的问题了。但当时的实现还有一个很明显的限制:它只允许调用一次工具。
先回忆一下上一篇的流程:
bash
用户提问
→ AI 申请读一次文件
→ 程序读取文件
→ AI 根据文件内容回答
这个过程可以用下面这张图来理解:

这条链路可以跑,但它更像一段提前写死的剧本。
比如你问它:
js
"先读 README.md,再看看 package.json,告诉我这个项目怎么启动"
它读完 README.md 后,可能还需要看 package.json。可上一篇的代码会直接让它开始回答,第二次读文件的机会根本没有。
真实的 AI agent不是这么干活的。它会像我们排查问题一样:先看一个文件,发现信息量不够,就再看另一个;直到拿到足够的信息后,才给出结论。
今天这一篇,我们不再替 AI 把步骤写死。我们让它读完一个文件后,可以想一想:现在掌握的信息够不够?不够就继续查,够了再回答。
这个"想一想 → 动手 → 看结果 → 再想一想"的过程,就是大家经常听到的 Agent Loop(Agent 循环)。
先看今天要实现的效果
代码写完后,运行同一条命令:
bash
npm run start -- -prompt "先读 README.md,再看看 package.json,告诉我这个项目怎么启动"
终端可能会看到这样的过程:
bash
AI 正在思考...
第 1 轮:AI 想调用 read_file
正在读取文件:README.md
第 2 轮:AI 想调用 read_file
正在读取文件:package.json
AI:这是一个 TypeScript 项目。先安装依赖,再执行 npm run build,最后执行 npm run start ...
这个过程可以用下面这张图来理解:

这个例子里,AI 第 1 轮先申请读取 README.md。程序读完后,把结果交回给它;AI 看完觉得信息还不够,于是在第 2 轮继续申请读取 package.json。拿到两个文件的内容后,它才给出最终回答。
这里先记住一个小点:一轮不一定只会调用一次工具。
有些模型会像上面这样,分两轮读取两个文件;也有些模型会在第 1 轮就同时申请两次 read_file,一次读 README.md,一次读 package.json。不管是哪种情况,咱们后面写的循环都能处理。
重点不在于它一共读了几个文件,而在于它终于会自己决定下一步:
bash
想一想
→ 调工具
→ 看结果
→ 再想一想
→ 继续调工具,或者回答用户
这个过程可以用下面这张图来理解:

这就是 Agent Loop(Agent 循环)。
🚀 本节配套源码:powercode
本篇是在第二篇代码的基础上继续修改。如果你还没跑过第二篇,建议先把
read_file跑通。
先把"循环"想明白
"Agent Loop"这个词听上去很唬人,但把它换成日常话,其实就是:
别替 AI 规定只能干几步;每做完一步,都再问它一次:现在还需要做什么?
你自己查一个陌生项目时,多半也是这个过程:
bash
用户:这个项目怎么启动?
你:先看看 README
你:README 只说了项目用途,不够,再看看 package.json
你:找到了 scripts,可以回答了
这个过程可以用下面这张图来理解:

AI 也一样。模型本身只负责判断"下一步该做什么";而我们的程序负责执行它申请的工具,并把结果交还给它。
可以把职责分成两边:
text
AI:决定下一步
程序:执行这一步,并把结果带回来
这个过程可以用下面这张图来理解:

只要 AI 还在申请工具,我们就继续循环;只要它开始输出普通文本,说明它觉得信息够了,循环就结束。
上一篇的代码,卡在哪里?
上一篇 src/main.ts 中有这段逻辑:
js
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}`);
它把流程固定成了:
text
第一次请求模型
→ 第一次工具调用
→ 第二次请求模型
→ 结束
这里要分清楚:用户从头到尾只问了一个问题;但程序为了完成这次回答,向模型发了两次请求。
第一次请求,模型决定要不要读文件;读完后,第二次请求把文件内容交给模型,让它组织最终答案。
问题不在 read_file,也不在模型,而在第二次请求时,我们没有再把 tools 工具传给模型,随后又直接打印答案并结束程序。也就是说,模型就算还想再读一个文件,也没有继续调用工具的机会。
所以今天我们不改工具本身,只把"写死的两次请求",换成"带退出条件的循环"。
第一步:别把聊天记录丢掉
先说一个特别关键的点:循环中的每次请求,都要带上之前发生过的事情。
想象你和同事聊天:
text
你:帮我看看 README。
同事:好的,我看到 README 了。
你:那 package.json 呢?
这个过程可以用下面这张图来理解:

如果每说一句都把前面的聊天记录清空,同事就不知道"那"指的是什么。
模型也是一样。每一轮都重新只传用户问题,它就不知道:
- 刚才已经申请过什么工具;
- 工具实际返回了什么;
- 哪些文件已经看过。
因此,我们需要一个 messages 数组,把整段对话一直保存下来。
这就是大家常说的上下文:每次请求模型时,我们把前面发生过的事一起带上,让它知道自己正做到哪一步。
这份 messages 里的内容,叫"消息历史"。你可以先把它理解成 Agent 的短期记忆;后面讲长任务时,我们还会看到更广义的上下文和记忆。
新建 src/agent.ts,先写一个最小骨架:
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 文件 。替换后,原来的 askWithTools 和 answerAfterReadFile 都会删除,统一换成新的 complete 方法。
ts
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 不关心当前是第几轮,也不关心模型会不会调用工具。它只负责一次模型请求。
这样分工之后会更清楚:
text
ChatClient:请求模型一次
Agent:决定要不要继续下一轮
第三步:先接住模型的每一句话
回到 src/agent.ts,把刚才的 return '暂未实现' 换成:
ts
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} 轮,已停止。`);
每轮做完三件事:
- 把当前完整的
messages发给模型; - 拿到模型新回复;
- 立刻把这条新回复也塞回
messages。
为什么模型的回复也要保存?因为它可能包含一次工具调用请求。后面我们把工具结果交回模型时,模型必须能对上:这个工具结果,到底是在回答哪一次调用。
到这里,循环已经有了;但它还不知道什么时候停,也不会真的执行工具。
第四步:模型不再要工具时,就结束
模型可能会返回两类消息:
第一类是普通回答:
text
这个项目可以通过 npm run build 构建,再用 npm run start 启动。
第二类是工具调用:
text
请调用 read_file,参数是 {"path":"package.json"}
在 OpenAI 兼容接口里,第二类会出现在 message.tool_calls 中。
这里顺便把这个名字讲明白。工具调用也经常叫 Function Calling :模型并不是真的自己去执行了 read_file,而是按接口约定,返回一份结构化的"工具申请单",告诉我们的程序"请帮我调用哪个工具、参数是什么"。在 Chat Completions 这套接口里,这份申请单就放在 tool_calls 字段中。
这套写法是 OpenAI 接口带火的。现在很多标明"OpenAI 兼容"的模型服务,也会沿用 tools、tool_calls 这些字段,所以咱们这套代码通常能直接接上国内模型;不过具体模型是否支持工具调用、支持哪些参数,还是要以它自己的文档为准。
如果模型这轮不需要工具,它的 tool_calls 通常会是空的,或者干脆没有这个字段。因此我们用 ?? [] 把"没有这个字段"也统一当成空数组来处理。
因此,在刚才 messages.push(message) 后面补上:
ts
const toolCalls = message.tool_calls ?? [];
if (toolCalls.length === 0) {
return message.content ?? '模型没有返回文本内容。';
}
这里判断的不是"模型有没有真的把文件读完",而是:它这一轮还要不要让我们的程序帮它干活。
读取文件是程序替它读的,模型只能提出申请。
于是逻辑就变成:
text
没有 tool_calls
→ AI 这轮没有再申请工具
→ 它认为目前拿到的信息已经够用
→ 直接把这轮文字回复给用户,循环结束
有 tool_calls
→ AI 还想让程序替它读文件、改文件或做别的事
→ 程序先执行工具,把结果告诉 AI
→ AI 再根据新结果想下一步
所以,tool_calls 为空代表模型不需要工具了。对咱们这个最小 Agent 来说,这时就把它返回的文字当作最终答案,结束循环。
这就是 Agent Loop 最核心的退出条件。
第五步:执行工具,并把结果塞回消息历史
回到刚才新建的 src/agent.ts。上一步咱们已经在外层的 for 循环里,写好了这段"没有工具就直接返回"的代码:
ts
if (toolCalls.length === 0) {
return message.content ?? '模型没有返回文本内容。';
}
现在在这段代码后面再粘贴下面的代码
ts
// 处理工具调用
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,
});
}
这段代码稍长,但只是在完整走一遍"申请 → 执行 → 回传":
bash
模型:我想读 package.json
程序:好的,正在读
程序:读到的内容是......
模型:收到,我继续判断下一步
最容易漏掉的是最后的 messages.push(...)。
它不是打印日志,而是把工具的真实结果以 tool 角色放回对话历史。下一轮请求模型时,这份结果就会一起传过去。
另外,我们没有在读取失败时直接让程序崩掉,而是把失败原因也告诉模型:
text
读取失败:ENOENT,文件不存在
这样模型还有机会换一个路径、或者告诉用户文件不存在。更完整的错误恢复会放到后面的篇章,这里先记住一句:工具失败也是信息,最好让 AI 看见。
完整的 src/agent.ts
为了方便你直接运行,下面是完整版本:
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.md 和 package.json。上一篇只取 [0],后面的调用会被忽略;现在我们会把这一轮请求里的每一个工具都执行完,再进入下一轮。
至于这一轮里出现好几个工具调用时,要不要让它们并发执行,咱们先不急着钻。先让它一个一个按顺序执行,逻辑最直观,也足够完成今天的目标。等后面工具多起来、任务更复杂时,我们再专门聊怎么让它们并发干活。
第六步:让入口只负责启动 Agent
打开 src/main.ts,删除里面原来的全部内容,再完整复制下面这份代码进去。不用保留上一章的工具调用处理逻辑。
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。
先编译:
bash
npm run build
再试一个明确需要多次读取的问题:
bash
npm run start -- -prompt "先读 README.md,再看看 package.json,告诉我这个项目怎么启动"
如果模型只读了一个文件就直接回答,不一定是代码有问题。模型认为已有信息足够时,本来就可以结束。
你也可以换一个更明确的提问
npm run start -- -prompt "必须分别读取 README.md 和 package.json。读取完后,告诉我项目用途、构建命令和启动命令"
这次重点看两件事:终端有没有真的读取对应文件;以及日志里的"第几轮"到底代表什么。

以截图里的结果为例,模型在第 1 轮 一次申请了两次 read_file:一次读 README.md,一次读 package.json。程序会按顺序把这两个文件读完,并把两个结果都放回 messages。
接着程序会再请求一次模型。模型拿到两个文件内容后,直接返回了最终回答,没有再申请工具,于是循环结束。
注意:终端里看起来没有"第 2 轮"的日志,并不代表没有第二次请求模型。我们现在的日志只会在 AI 申请工具时打印;第 2 次请求直接得到文字答案,自然不会打印"AI 想调用 read_file"。
为什么一定要设最大轮数?
既然叫循环,很多人会自然想到:那就一直循环到 AI 回答不就行了?
不行。因为模型并不保证每次都做出最优决定,它可能:
- 连续读取同一个文件;
- 一直尝试不存在的路径;
- 因为提示不清楚,反复收集用不上的信息。
如果没有上限,程序会一直请求模型、一直花 Token,甚至一直跑不完。
所以我们加了:
ts
const MAX_STEPS = 8;
它像给 Agent 配了一个倒计时:八轮以内没完成,就先停下来,告诉我们发生了什么。
这个数字没有绝对标准。学习项目里用 8 比较容易观察;真正项目里可以按任务类型、成本和超时策略来设置。重要的不是具体数字,而是:只要有循环,就必须有明确退出条件和上限。
到这里,我们真正拥有了什么?
现在的 power-code 还只会读文件,但它已经不再是"只能调用一次工具的聊天程序"了。
它有了最核心的一套工作节奏:
text
用户提出任务
→ 模型判断下一步
→ 程序执行工具
→ 工具结果回到消息历史
→ 模型继续判断
→ 直到模型给出最终答案
后面我们再给它加 write_file、edit_file、bash,这个agent循环不用推倒重来。我们只需要让它在"执行工具"时认识更多工具就可以了。
到这里你应该能发现:光有一个聪明的大模型不够,光塞给它一堆工具也不够。
工具解决的是"它能不能动手";而这个循环解决的是"它动完这一步以后,要不要继续干下一步"。
下一篇预告
现在有一个新问题:如果工具越来越多,难道要在 agent.ts 里不断写:
ts
if (toolCall.function.name === 'read_file') {
// ...
}
那很快就会乱。
下一篇,咱们就来做一个"工具管理中心":给每个工具统一定义、统一注册、统一执行。到那时,除了读文件,AI 终于可以开始写文件、精确修改文件和执行命令。
如果这篇跑通了,你就已经摸到了绝大多数 AI Agent 最关键的骨架。后面加再多能力,本质上也都是往这个循环里接工具、加规则。