Skip to content

03. 最小可用核心

设计目标

本章的目标是实现一个 最小可运行(MVP) 的 Agent 核心循环。我们将在这个阶段暂时不调用真实大模型,而是通过写一个精简的正则模拟器(MockLLMProvider)来验证整个控制链路是否能跑通:

text
[ 用户输入 ] 


┌──────────────────┐
│    Agent 循环    │ ◄─── (判断是否需要工具,最多 maxToolRounds 轮)
└────────┬─────────┘

         ├── [需要工具] ──► ┌────────────────┐ ──► 执行 ──► 将结果加入消息历史 ──┐
         │                  │  ToolRegistry  │                                  │
         │                  └────────────────┘                                  │
         └── [最终回答] ◄───────────────────────────────────────────────────────┘


[ 屏幕输出 ]

为什么先做最小核心?

在构建复杂系统(如 Agent Harness)时,最容易犯的错误是一开始就配置真实 API、处理网络重试、设计海量数据库 schema。这会导致大量的调试时间被浪费在网络超时和费用开销上。

我们采用的工程策略是:先跑通垂直切片(Vertical Slice),再系统化改进。

这有助于我们:

  • 在不引入网络和 API Key 复杂度的前提下,设计出清晰的 LLMProviderToolRegistry 类型边界。
  • 调试好多轮对话中消息历史数组的追加逻辑,避免后续真实 LLM 执行时出现格式错乱。

核心类型定义

packages/core/src/types/index.ts 中,我们为核心循环定义了这几个不可动摇的契约类型:

ts
/**
 * 消息结构
 */
export interface Message {
  id: string;
  role: "user" | "assistant" | "system" | "tool";
  content: string;
  timestamp: number;
  tool_call_id?: string; // 仅在 role === "tool" 时关联工具调用的 id
  name?: string;         // 仅在 role === "tool" 时记录工具名称
}

/**
 * 模拟大模型的返回结构
 */
export interface LLMResponse {
  content: string | null;
  tool_calls?: ToolCall[];
}

export interface ToolCall {
  id: string;
  name: string;
  arguments: Record<string, unknown>;
}

/**
 * 大模型提供商接口
 */
export interface LLMProvider {
  chat(messages: Message[], tools?: ToolDefinition[]): Promise<LLMResponse>;
}

/**
 * 工具定义结构
 */
export interface ToolDefinition {
  name: string;
  description: string;
  parameters: Record<string, unknown>; // JSON Schema
  execute: (args: Record<string, unknown>) => Promise<string>;
}

模块实现

1. ToolRegistry 实现

负责维护可用工具的 Map 注册表,并在 Agent 下发工具调用时安全地执行它们。

新建文件 packages/core/src/tools/registry.ts

ts
import type { ToolDefinition } from "../types/index.js";

export class ToolRegistry {
  private tools = new Map<string, ToolDefinition>();

  /**
   * 注册工具
   */
  register(tool: ToolDefinition) {
    if (this.tools.has(tool.name)) {
      throw new Error(`Tool already registered: ${tool.name}`);
    }
    this.tools.set(tool.name, tool);
  }

  /**
   * 获取指定工具
   */
  get(name: string): ToolDefinition | undefined {
    return this.tools.get(name);
  }

  /**
   * 列出所有已注册的工具
   */
  list(): ToolDefinition[] {
    return Array.from(this.tools.values());
  }

  /**
   * 安全执行工具并捕获异常
   */
  async execute(name: string, args: Record<string, unknown>): Promise<string> {
    const tool = this.tools.get(name);
    if (!tool) {
      throw new Error(`Tool not found: ${name}`);
    }
    try {
      // 串行执行,传入空 Context 占位
      return await tool.execute(args);
    } catch (err) {
      const message = err instanceof Error ? err.message : String(err);
      return `Error executing tool ${name}: ${message}`;
    }
  }
}

TIP

execute 内部包裹 try...catch 是非常关键的健壮性设计。我们不希望由于工具本身的代码缺陷(比如除零错误),导致整个 Agent 的核心循环直接崩溃中断。

2. MockLLMProvider 实现

不需要网络,使用简单的正则匹配逻辑来扮演“大模型”的智能。当它在最后的 user 消息中检测到类似“$123 + 456$”的算式时,它会输出一个 tool_calls 要求调用计算器工具;否则直接回复文本。

新建文件 packages/core/src/agent/llm.ts

ts
import type { Message, ToolDefinition, LLMResponse, LLMProvider } from "../types/index.js";
import { generateId } from "@hachimi/shared";

export class MockLLMProvider implements LLMProvider {
  async chat(messages: Message[], tools: ToolDefinition[] = []): Promise<LLMResponse> {
    const lastMessage = messages[messages.length - 1];

    // 1. 如果上一轮是工具执行结果,Mock 直接整理回复
    if (lastMessage?.role === "tool") {
      return { content: `计算结果是:${lastMessage.content}` };
    }

    // 2. 检索最后一轮用户输入
    const lastUser = [...messages].reverse().find((m) => m.role === "user");
    const userContent = typeof lastUser?.content === "string" ? lastUser.content : "";

    // 3. 正则匹配算术算式(例如 "123 + 456")
    const hasCalculator = tools.some((t) => t.name === "calculator");
    const calcMatch = userContent.match(/(\d+)\s*([\+\-\*\/])\s*(\d+)/);

    if (hasCalculator && calcMatch) {
      const [, a, op, b] = calcMatch;
      return {
        content: null,
        tool_calls: [
          {
            id: generateId("call_"),
            name: "calculator",
            arguments: { a: Number(a), b: Number(b), operator: op },
          },
        ],
      };
    }

    // 4. 默认对话回复
    return {
      content: `我是 hachimi 的 MockLLM。你刚才说:${userContent}`,
    };
  }
}

3. Agent 核心控制循环

这是最核心的部分。它控制着一个 while 循环,将思考过程与工具调用连贯起来。每次模型返回包含 tool_calls 时,核心会执行工具、将结果合并入消息数组,并再次呼叫模型,直到模型决定输出普通文本或达到上限轮次为止。

新建文件 packages/core/src/agent/agent.ts(简化版):

ts
import type { Message, LLMProvider, ToolDefinition } from "../types/index.js";
import { ToolRegistry } from "../tools/registry.js";
import { generateId } from "@hachimi/shared";

export interface AgentOptions {
  llm: LLMProvider;
  tools: ToolRegistry;
  maxToolRounds?: number;
}

export class Agent {
  private llm: LLMProvider;
  private tools: ToolRegistry;
  private maxToolRounds: number;

  constructor(options: AgentOptions) {
    this.llm = options.llm;
    this.tools = options.tools;
    this.maxToolRounds = options.maxToolRounds ?? 5;
  }

  async run(userInput: string): Promise<string> {
    const messages: Message[] = [
      {
        id: generateId("msg_"),
        role: "user",
        content: userInput,
        timestamp: Date.now(),
      },
    ];

    let rounds = 0;

    // 核心 ReACT 循环开始
    while (rounds < this.maxToolRounds) {
      rounds++;

      const toolDefs = this.tools.list();
      const response = await this.llm.chat(messages, toolDefs);

      // 情况 A:没有工具调用,直接作为最终结果返回
      if (!response.tool_calls || response.tool_calls.length === 0) {
        return response.content ?? "";
      }

      // 情况 B:有工具调用,开始依次执行并将结果回流
      messages.push({
        id: generateId("msg_"),
        role: "assistant",
        content: response.content ?? "",
        timestamp: Date.now(),
      });

      for (const call of response.tool_calls) {
        // 调用 ToolRegistry 执行工具
        const result = await this.tools.execute(call.name, call.arguments);

        // 将工具执行结果作为特殊 role 追加进历史,以便 LLM 看到
        messages.push({
          id: generateId("msg_"),
          role: "tool",
          content: result,
          tool_call_id: call.id,
          name: call.name,
          timestamp: Date.now(),
        });
      }
    }

    return "达到最大工具调用轮次,已自动停止执行。";
  }
}

验证闭环

我们在根目录新建测试脚本 scripts/test-phase1.ts

ts
import { Agent, ToolRegistry, MockLLMProvider } from "../packages/core/src/index.js";

async function main() {
  const tools = new ToolRegistry();

  // 1. 注册一个加减乘除计算器工具
  tools.register({
    name: "calculator",
    description: "执行简单的加减乘除计算",
    parameters: {
      type: "object",
      properties: {
        a: { type: "number" },
        b: { type: "number" },
        operator: { type: "string", enum: ["+", "-", "*", "/"] },
      },
      required: ["a", "b", "operator"],
    },
    async execute(args) {
      const { a, b, operator } = args as { a: number; b: number; operator: string };
      switch (operator) {
        case "+": return String(a + b);
        case "-": return String(a - b);
        case "*": return String(a * b);
        case "/": return String(a / b);
        default: return "不支持的运算符";
      }
    },
  });

  const agent = new Agent({
    llm: new MockLLMProvider(),
    tools,
  });

  console.log("=== Test 1: 普通对话 ===");
  console.log(await agent.run("你好,你是谁?"));

  console.log("\n=== Test 2: 触发工具调用 ===");
  console.log(await agent.run("请帮我计算 123 + 456"));
}

main().catch(console.error);

运行测试

在项目根目录下通过 tsx 执行测试脚本:

bash
pnpm install
npx tsx scripts/test-phase1.ts

运行输出:

text
=== Test 1: 普通对话 ===
我是 hachimi 的 MockLLM。你刚才说:你好,你是谁?

=== Test 2: 触发工具调用 ===
计算结果是:579

恭喜你!在没有任何第三方外部网络开销的情况下,我们第一个 ReACT(Reasoning and Acting)控制闭环完美跑通!


避坑指南:工具调用死循环

在早期设计 MockLLMProvider 时,由于未保存上一轮的助理和工具消息状态,大模型容易陷入死循环。

  • 问题现象:当工具执行完毕并返回结果时,大模型在下一轮对话中重新看到原始的 User 输入,然后再次给出完全相同的 tool_calls,反复循环直到超出轮次被强行打断。
  • 解决方案:在 MockLLMProvider 中一定要做 lastMessage.role === "tool" 拦截。在接入真实大模型时,我们将依靠大模型的内在推理逻辑来规避这一现象,但必须确保上一轮的 assistant message 以及所有的 tool 结果按正确的先后顺序一并提交给 API。

本章总结

本章我们:

  • 确立了 Agent 循环最核心的数据结构和类型边界。
  • 编写了统一管理工具的 ToolRegistry
  • 实现了一个轻量级本地测试大模型 MockLLMProvider,利用正则表达式触发工具。
  • 编写了 Agent 控制流,实现了基于 ReACT 协议的多轮工具调用,并在本地成功验证。

目前我们的 Agent 运行一次就清空了所有状态。在下一章,我们将为它注入第一项核心特长——分层 Memory