1. 项目启动与架构总览

你准备从零搭建一个可扩展的 TypeScript Agent 运行时核心,这篇文章告诉你为什么要有这个项目、整体架构怎么划分、核心接口怎么设计,以及为什么接口先行比写代码更重要。

今年的 AI 圈,人人都在做 Agent。LangChain、Vercel AI SDK、 Semantic Kernel……轮子一大堆,但我还是决定自己搭一个。

不是闲的。

因为用别人的框架写了两个月后,我发现一个尴尬的事实:当你的项目长大了,框架的抽象开始漏风。你想加个自定义记忆策略,框架说"你继承这个基类就行";你想换一种工具调用方式,框架说"抱歉我们只支持 OpenAI 格式"。每个抽象都在帮你省事,也在悄无声息地限制你。

所以我决定从头写一个,不是为了造轮子,而是为了知道轮子为什么长那样

这个项目的定位

先说清楚这不是什么。这不是要和 LangChain 竞争的框架。这是一个主干项目——它只搭骨架,不填血肉。后续我会在这个骨架上开多个系列:加 Redis 模块、加 MySQL 模块、加记忆系统增强……每个系列是往主干上添一个功能模块,而不是另起炉灶。

所以整个架构的第一原则是:这个项目必须能长

这不是一个框架,这是一个生长中的项目

从哪里开始

首先确定技术栈:

  • TypeScript — 纯 ESM,Node.js 24+。应用层 Agent 框架的战场在 TS 这边,没什么好纠结的
  • Zod — 参数校验和 schema 推导。TypeScript 的参数校验生态里 Zod 是事实标准,后面做 tool calling 时你会体会到它的好
  • Vitest — 测试。快,而且和 Vite 生态兼容
  • 没有 HTTP 框架 — 这个项目是运行时核心,不是 web 服务。加 HTTP 层留给后续系列做

创建项目:

mkdir ts-agent-core && cd ts-agent-core
npm init -y
npm install zod
npm install -D typescript vitest @types/node

然后一个干净的 tsconfig.json

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "Node16",
    "moduleResolution": "Node16",
    "lib": ["ES2022"],
    "outDir": "./dist",
    "rootDir": "./src",
    "strict": true,
    "esModuleInterop": true,
    "declaration": true,
    "declarationMap": true,
    "sourceMap": true,
    "types": ["node"]
  },
  "include": ["src/**/*"]
}

注意 module: Node16——这个选项会让 TypeScript 要求你写 .js 后缀的 import,初看有点烦,但它保证了 ESM 的正确性。

目录结构怎么切

src/
├── core/          # 核心接口与类型定义
├── runloop/       # Agent 主循环实现
├── tools/         # 内置工具实现
├── memory/        # 记忆系统实现
├── llm/           # LLM 客户端实现
└── index.ts       # 入口

这里最关键的决策是:core 只放接口和类型,不放实现。任何具体实现如果和 core 放在一起,日后想替换就只能改 core。而 core 一旦被多处 import,改它的成本就指数级上升。

接口放 core,实现放自己的模块。这样后续系列只需要 import 接口、实现它、注册,不需要碰 core 一行代码。

先定义消息模型——一切从这里开始

在核心抽象里,消息模型是最底层的。工具调用、记忆存储、LLM 通信,全都依赖消息格式。

// src/core/message.ts

export type MessageRole = 'system' | 'user' | 'assistant' | 'tool';

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

export interface Message {
  role: MessageRole;
  content: string;
  toolCalls?: ToolCall[];
  toolCallId?: string;
  toolName?: string;
  isError?: boolean;
  metadata?: Record<string, unknown>;
}

你可能会问为什么不用现成的 OpenAI SDK 类型?因为一旦依赖了外部类型,你的整个项目就和那个 SDK 绑死了。今天用 OpenAI,明天想切 Anthropic,后天想用本地 Ollama,每个厂商的消息格式都有自己的小脾气。

接口是自己的,实现可以换。 LLM Provider 负责在自己的 complete() 方法里做消息格式转换,core 不关心外界长什么样。

Tool 系统——Agent 的延伸

Tool 是整个架构里对扩展性要求最高的部分。后续的 Redis 系列、MySQL 系列,都是通过实现 Tool 接口来接入的。

// src/core/tool.ts - 核心接口

export interface Tool {
  metadata: ToolMetadata;
  execute(args: Record<string, unknown>, ctx?: ToolContext): Promise<ToolResult>;
}

export interface ToolMetadata {
  name: string;
  description: string;
  parameters: z.ZodSchema<unknown>;
}

export interface ToolRegistry {
  register(tool: Tool): void;
  get(name: string): Tool | undefined;
  listMetadata(): ToolMetadata[];
}

注意 ToolRegistry 也是一个接口。这是故意的——后续系列如果想加一个"自动发现"的能力(比如扫描某个目录自动注册工具),只需要实现一个新的 ToolRegistry,不需要改 Agent 核心。

Zod schema 在这里做了两个工作:校验参数是否符合预期,同时也向 LLM 输出 JSON Schema(function calling 需要这个描述)。

记忆系统——短期对话+长期知识

Agent 的短板之一是记不住事。记忆系统分两层:

  • 短期记忆:最近几轮对话的滑动窗口,满了就淘汰最早的。简单粗暴但有效
  • 长期记忆:持久化的知识存储,支持检索和遗忘
// src/core/memory.ts - 核心接口

export interface ShortTermMemory {
  add(message: Message): void;
  getMessages(): Message[];
  clear(): void;
}

export interface LongTermMemory {
  store(item: Omit<LongTermMemoryItem, 'id'>): Promise<string>;
  search(query: LongTermMemoryQuery): Promise<LongTermMemoryItem[]>;
  forget(threshold: number): Promise<number>;
}

export interface MemorySystem {
  shortTerm: ShortTermMemory;
  longTerm: LongTermMemory;
  consolidate(): Promise<void>;
}

consolidate() 是连接短期和长期的桥梁——从短期记忆中提炼关键信息存入长期记忆。这个策略怎么设计、什么时候触发,是后续系列可以大做文章的地方。

forget() 是遗忘机制的入口。你可能觉得遗忘很奇怪,但人脑的遗忘恰恰是记忆系统高效的秘密。后续系列可以基于重要性评分实现遗忘策略。

LLM Provider——抽象一层,后顾无忧

OpenAI、Anthropic、Google、本地模型……API 千奇百怪,但核心能力是一回事:给消息,拿回复。

// src/core/llm.ts - 核心接口

export interface LLMProvider {
  readonly name: string;
  complete(request: LLMRequest): Promise<LLMResponse>;
  completeStream(
    request: LLMRequest,
    onChunk: (chunk: LLMChunk) => void
  ): Promise<LLMResponse>;
}

export interface LLMRequest {
  messages: Message[];
  tools?: ToolMetadata[];
  toolChoice?: 'auto' | 'none' | 'required';
  maxTokens?: number;
  temperature?: number;
}

注意 extraParams 字段——这个小小的逃生舱口很重要。每个厂商都有一些独家参数(OpenAI 的 response_format、Anthropic 的 thinking),如果接口里不放过这样一个兜底字段,要么你的抽象层永远追不上厂商的更新,要么你被迫给每个厂商加一个专用参数,接口越来越臃肿。

抽象层应该是"漏斗"而不是"滤网":夹住最关键的部分,让不重要的差异从逃生口流过。

Agent——把一切串起来

// src/core/agent.ts

export interface AgentConfig {
  name: string;
  model: ModelConfig;
  systemPrompt: string;
  tools: ToolRegistry;
  memory: MemorySystem;
  maxIterations?: number;
}

export interface Agent {
  readonly config: AgentConfig;
  run(input: string | Message[]): Promise<AgentResult>;
  reset(): void;
}

Agent 不直接持有任何具体模块的引用。它拿到的是一个 ModelConfig(里面包含 Provider)、一个 ToolRegistry、一个 MemorySystem。所有这些都是在外面组装好再注入的。

这种组装方式叫依赖注入——虽然没用任何 DI 框架,但思路是一样的。Agent 只知道自己需要什么能力,不关心能力来自哪里。

为什么要这么"绕"?

你可能会觉得就一个小项目,搞这么多接口是不是过度设计。

直接一点说:如果你只写一个 Agent,这些接口确实是多余的。

但如果你要持续在上面加功能——加 Redis 持久化、加 MySQL 工具、加不同的记忆策略——没有这些接口,你就会发现改一处导致其他模块全崩。

到时候再回头加抽象,成本远高于一开始就用接口兜住。

能看到代码在跑

最后验证一下项目能不能编译:

npm run build

没有任何报错,dist/ 目录下生成了完整的 .js.d.ts.js.map 文件。这意味着你的包可以被其他项目 import 了:

import { DefaultToolRegistry, InMemorySession } from 'ts-agent-core';

当然现在只是个空壳——只有类型定义,没有任何运行时的 Agent 逻辑。但壳子有了,往里填东西就只是时间问题。

下一篇,我会把这个壳子变成能跑起来的 Agent 主循环。

评论

此博客中的热门博文

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