03. 最小可用核心
设计目标
本章的目标是实现一个 最小可运行(MVP) 的 Agent 核心循环。我们将在这个阶段暂时不调用真实大模型,而是通过写一个精简的正则模拟器(MockLLMProvider)来验证整个控制链路是否能跑通:
[ 用户输入 ]
│
▼
┌──────────────────┐
│ Agent 循环 │ ◄─── (判断是否需要工具,最多 maxToolRounds 轮)
└────────┬─────────┘
│
├── [需要工具] ──► ┌────────────────┐ ──► 执行 ──► 将结果加入消息历史 ──┐
│ │ ToolRegistry │ │
│ └────────────────┘ │
└── [最终回答] ◄───────────────────────────────────────────────────────┘
│
▼
[ 屏幕输出 ]为什么先做最小核心?
在构建复杂系统(如 Agent Harness)时,最容易犯的错误是一开始就配置真实 API、处理网络重试、设计海量数据库 schema。这会导致大量的调试时间被浪费在网络超时和费用开销上。
我们采用的工程策略是:先跑通垂直切片(Vertical Slice),再系统化改进。
这有助于我们:
- 在不引入网络和 API Key 复杂度的前提下,设计出清晰的
LLMProvider和ToolRegistry类型边界。 - 调试好多轮对话中消息历史数组的追加逻辑,避免后续真实 LLM 执行时出现格式错乱。
核心类型定义
在 packages/core/src/types/index.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:
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:
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(简化版):
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:
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 执行测试脚本:
pnpm install
npx tsx scripts/test-phase1.ts运行输出:
=== Test 1: 普通对话 ===
我是 hachimi 的 MockLLM。你刚才说:你好,你是谁?
=== Test 2: 触发工具调用 ===
计算结果是:579恭喜你!在没有任何第三方外部网络开销的情况下,我们第一个 ReACT(Reasoning and Acting)控制闭环完美跑通!
避坑指南:工具调用死循环
在早期设计 MockLLMProvider 时,由于未保存上一轮的助理和工具消息状态,大模型容易陷入死循环。
- 问题现象:当工具执行完毕并返回结果时,大模型在下一轮对话中重新看到原始的 User 输入,然后再次给出完全相同的
tool_calls,反复循环直到超出轮次被强行打断。 - 解决方案:在
MockLLMProvider中一定要做lastMessage.role === "tool"拦截。在接入真实大模型时,我们将依靠大模型的内在推理逻辑来规避这一现象,但必须确保上一轮的assistantmessage 以及所有的tool结果按正确的先后顺序一并提交给 API。
本章总结
本章我们:
- 确立了 Agent 循环最核心的数据结构和类型边界。
- 编写了统一管理工具的
ToolRegistry。 - 实现了一个轻量级本地测试大模型
MockLLMProvider,利用正则表达式触发工具。 - 编写了 Agent 控制流,实现了基于 ReACT 协议的多轮工具调用,并在本地成功验证。
目前我们的 Agent 运行一次就清空了所有状态。在下一章,我们将为它注入第一项核心特长——分层 Memory。