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 系统,讲解怎么设计一个生产级的工具定义——参数校验、错误类型、执行上下文、并行执行。然后我们会实现几个真正能用的工具做演示。

下一篇见。

评论

此博客中的热门博文

我写了半年 prompt,最后发现最好的技巧就三个