2. Agent 主循环——AI 的"心跳"
上一篇搭好了骨架,这一篇让 Agent 真正"活"起来——实现那个让 LLM 和自己反复对话、调用工具、直到给出最终答案的主循环。
上篇文章我们把接口都定了,目录也划好了。但如果你跑一下现在的代码,它什么都不会做。
这很正常。接口定义了 Agent 长什么样,但还没告诉它怎么运转。就像你设计好了引擎的蓝图,但没给点火钥匙。
这个点火钥匙就是主循环(Run Loop)。
什么是主循环
传统程序是确定性的:你调用一个函数,它返回结果,结束。用户发一个 HTTP 请求,你处理,返回响应,结束。
Agent 不是这样的。
Agent 的工作模式是:LLM 说一句话,你看它是不是想调用工具。如果是,你执行工具,把结果喂回去让 LLM 继续说。重复这个过程,直到 LLM 给出最终答案。
这个过程不是一次性的,是一个循环。所以叫主循环。
用户输入 → [LLM 调用 → 检查 tool_calls → 执行工具 → 反馈结果] × N → 最终回答
方括号里的内容可能会反复执行多次。你永远不知道 LLM 需要几轮才能完成用户的请求。
一个简单的主循环长什么样
把上面的流程翻译成代码,核心就是一个 while 循环:
while (iterations < maxIterations) {
// 1. 把当前对话发给 LLM
const response = await llm.complete({ messages, tools });
// 2. 把 LLM 的回复加入对话
messages.push(response);
// 3. 如果没有 tool_calls,结束
if (!response.toolCalls) break;
// 4. 执行每个工具调用
for (const tc of response.toolCalls) {
const result = await executeTool(tc);
messages.push(result);
}
}
核心逻辑就这么几行。但工程化的主循环要处理的事情远比这个多:错误怎么隔离?记忆怎么集成?怎么避免死循环?怎么给外部留扩展点?
这就是我在这篇文章里要展开的。
完整的 DefaultAgent
我把它放在 src/runloop/agent.ts。
先看类的基本结构:
export class DefaultAgent implements Agent {
readonly config: AgentConfig;
hooks?: AgentHooks;
constructor(config: AgentConfig) {
this.config = config;
}
}
很直接。Agent 接口已经在上一篇定义好了,这里只是实现它。
等一下——你可能注意到 hooks 是公开的字段而不是构造函数参数。这是故意的。因为 hooks 往往是外部在 Agent 创建之后才注入的,比如监控系统想在 Agent 跑起来之后才 attach 日志 hook。如果硬塞到构造函数里,调用方就得提前准备好一切,不够灵活。
run() 的完整实现
run() 是这个类的核心方法,它实现了整个主循环。
第一步,准备消息列表:
const messages: Message[] = [];
// 加载记忆中的历史对话作为上下文
const history = this.config.memory.shortTerm.getMessages();
messages.push(...history);
// 系统提示词插到最前面
if (this.config.systemPrompt) {
messages.unshift(systemMessage(this.config.systemPrompt));
}
// 追加用户输入
if (typeof input === 'string') {
messages.push(userMessage(input));
} else {
messages.push(...input);
}
这里有个有意思的决策。消息列表是局部变量,没有直接写到 this.messages 之类的实例字段上。
为什么?
因为在同一个 run() 内部,我们需要完全控制消息的顺序。插入工具调用结果、追加 LLM 回复——这些操作不能受外部影响。如果消息存在实例字段上,万一有其他方法不小心改了它,整个循环就崩了。局部变量是最安全的:只有 run() 自己能看到,跑完就回收。
第二步,进入主循环:
while (iterations < maxIterations) {
iterations++;
// 获取当前已注册的所有工具元数据
const toolMetadatas = this.config.tools.listMetadata();
// Hook: LLM 调用前
if (this.hooks?.onBeforeLLMCall) {
await this.hooks.onBeforeLLMCall(messages);
}
// 调用 LLM
const response = await this.config.model.provider.complete({
messages,
tools: toolMetadatas.length > 0 ? toolMetadatas : undefined,
maxTokens: this.config.model.maxTokens,
temperature: this.config.model.temperature,
});
// Hook: LLM 调用后
if (this.hooks?.onAfterLLMCall) {
await this.hooks.onAfterLLMCall(response);
}
totalTokens += response.usage?.totalTokens ?? 0;
lastResponse = response.content;
// 将 LLM 的回复加入对话
messages.push(assistantMessage(response.content, response.toolCalls));
每轮迭代的核心操作:
1. 从 ToolRegistry 拉取最新的工具列表
2. 调用 hooks(如果有的话)
3. 调用 LLM
4. 统计 token 用量
5. 把 LLM 的回复加入对话
第三步,处理工具调用。这是主循环里最需要小心设计的地方:
// 逐个执行工具调用(错误隔离)
if (response.toolCalls && response.toolCalls.length > 0) {
for (const tc of response.toolCalls) {
if (this.hooks?.onBeforeToolCall) {
await this.hooks.onBeforeToolCall(tc.name, tc.args);
}
const tool = this.config.tools.get(tc.name);
let resultContent: string;
let isError = false;
if (!tool) {
resultContent = `Error: Tool '${tc.name}' not found.`;
isError = true;
} else {
try {
const result = await tool.execute(tc.args);
resultContent = result.output;
isError = !result.success;
} catch (err) {
resultContent = `Error executing tool: ${err}`;
isError = true;
}
}
// ...push tool result to messages
}
}
关键的设计选择:每个工具调用是独立 try-catch 的。
这意味着一个工具挂了不会连带其他工具一起崩。如果 LLM 一口气要求调三个工具,第二个抛了异常,第一和第三个依然能拿到结果。
这符合 Agent 工具的隔离原则:工具是插拔的,失败的应该被当作信息反馈给 LLM,而不是让整个 Agent 崩溃。
第四步,迭代完成后的处理:
// Hook: 每轮迭代完成
if (this.hooks?.onIterationComplete) {
await this.hooks.onIterationComplete(iterations);
}
// 没有 tool_calls → 最终答案,循环结束
if (!response.toolCalls || response.toolCalls.length === 0) {
break;
}
}
注意 onIterationComplete 在 break 之前触发。这意味着即使最后一轮没有工具调用,hook 也会被调用。这样监控系统可以记录到完整的迭代次数。
这个决策是我写测试的时候才确定的。一开始我把 hook 放在了 break 后面,结果测试发现最后一轮迭代的完成事件丢了。测试驱动设计不是空话——你真的在写测试的时候才会发现接口设计的不合理。
记忆同步
主循环结束后,需要把这次对话写入短期记忆:
// 排除 system prompt,因为每次 run() 都会重新 prepend
const conversationMessages = messages.filter((m) => m.role !== 'system');
for (const msg of conversationMessages) {
this.config.memory.shortTerm.add(msg);
}
为什么不在循环中实时写记忆?因为在循环过程中,记忆的滑动窗口可能会因为 token 超限而触发 eviction,把还没完成的中间步骤挤掉。
比如 LLM 调用了工具,工具结果已经放入了 memory,但下一轮循环开始前 memory 的 eviction 策略把它删了。LLM 就看不到工具结果了,会以为工具没执行。
Run Loop 期间消息的稳定性比实时性更重要。 等循环结束再统一写入,是更安全的选择。
maxIterations 是逃生口
maxIterations(默认值 10)不是性能优化参数,是一个逃生口。
LLM 有时会陷入循环——一直调用工具,永远不给出最终答案。如果代码里没有这个上限,用户请求会永远跑下去,烧完 token 预算之后再烧光你的钱包。
有人会问:10 次够吗?够的。真实场景里 agent 很少超过 3-5 轮。如果超过了 10 轮还没结束,99% 的情况是 LLM 卡住了,不是它还需要更多轮。
整体流程回顾
整个 run() 的执行流程可以用这张图概括:
run(input)
│
├─ 加载记忆中的历史消息
├─ 插入 system prompt
├─ 追加用户输入
│
└─ while (iterations < maxIterations)
│
├─ 调用 LLM(携带当前消息 + 工具列表)
├─ 追加 LLM 回复到消息列表
│
├─ if (有 tool_calls)
│ └─ for each tool_call
│ ├─ 执行工具(try-catch 隔离)
│ └─ 追加工具结果到消息列表
│
├─ trigger onIterationComplete
│
└─ if (没有 tool_calls) → break
│
├─ 同步对话到短期记忆
└─ 返回 AgentResult { output, iterations, totalTokens, ... }
测试验证
空说无凭,我用 Vitest 写了 9 个测试用例覆盖了所有关键路径:
✓ 直接返回 LLM 的回答(无工具调用)
✓ 支持一次工具调用并返回最终结果
✓ 支持多轮工具调用
✓ 达到 maxIterations 时提前终止
✓ 工具不存在时优雅报告错误
✓ 工具抛出异常时不影响 Agent 运行
✓ 将对话保存到短期记忆,供后续 run 使用
✓ 正确触发所有 Hooks
✓ reset() 清除短期记忆
全部通过。我在本地跑了很多次确保稳定性。
其中一个测试用例可以让你直观感受 Agent 主循环的完整工作流:
// 模拟 LLM 返回一次 tool_calls,再返回最终回答
const provider = createMockProvider([
{
content: '',
finishReason: 'tool_calls',
toolCalls: [{ id: 'call_1', name: 'calculator', args: { expression: '1+2' } }],
},
{
content: 'The result of 1 + 2 is 3.',
finishReason: 'stop',
},
]);
const agent = createTestAgent({ provider, tools: [calculatorTool] });
const result = await agent.run('What is 1+2?');
expect(result.output).toBe('The result of 1 + 2 is 3.');
expect(result.iterations).toBe(2);
这个测试验证了完整的 Think → Act → Observe 循环:LLM 决定调用计算器工具 → 工具执行 → 结果反馈给 LLM → LLM 生成最终回答。两轮迭代,干净利落。
测试里用到的一个很巧妙的设计是 createMockProvider——它接受一个预设的 LLM 响应序列,然后按顺序返回。这样你可以精确控制 LLM 在每一轮说什么,测试场景 100% 可复现。
为什么这个设计能"长"
回顾一下第一篇文章的核心目标:这个项目必须能长。
DefaultAgent 的实现是怎么体现这个原则的?
第一,它不关心 LLM 背后是什么。你看 run() 里有一行代码直接依赖具体的 LLM API 吗?没有。它只依赖 ModelConfig.provider.complete() 这个接口。今天用 OpenAI,明天换 Anthropic,只需要换个 Provider 实现,Agent 代码一个字都不用改。
第二,工具系统是插拔的。this.config.tools.listMetadata() 和 this.config.tools.get(name) 是 Agent 和工具系统的全部交互。后续系列加 RedisTool、MySQLTool,只需要实现 Tool 接口并注册,run() 不需要知道它们的存在。
第三,Hooks 系统为监控和追踪预留了扩展点。你可以在 Agent 跑起来之后 attach 一个日志 hook,把每次 LLM 调用和工具执行的参数记录下来。或者 attach 一个 metrics hook,统计每次调用的耗时和 token 用量。这些都不需要改 Agent 的代码。
这些设计不是为了"优雅"而存在的炫技。它们解决的实际问题是:当你需要加功能的时候,不需要打开这个文件。 DefaultAgent 写好之后,我希望它永远不需要再被修改。
资源消耗
这个主循环目前有个明显的性能问题:每次迭代都把整个消息历史发给 LLM。随着对话变长,token 消耗会线性增长。短期记忆的滑动窗口会帮忙裁剪,但窗口内的消息还是会越来越长。
解决方案在后续文章里——记忆系统深度优化、压缩和摘要。目前够用就好。
下一篇预告
主循环写好了,Agent 能跑了。但到现在为止我们用的工具还是一个仅供测试的计算器。下一篇,我会深入 Tool 系统,讲解怎么设计一个生产级的工具定义——参数校验、错误类型、执行上下文、并行执行。然后我们会实现几个真正能用的工具做演示。
下一篇见。
评论
发表评论