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 主循环。
评论
发表评论