1. 项目缘起与核心目标

最近几年,AI Agent 这个概念火得不行,从 AutoGPT 到各种智能体框架,感觉不搞点 Agent 相关的东西,都不好意思说自己在做前沿开发。但说实话,很多现成的框架要么太重,要么定制化程度不够,想快速验证一个想法或者集成到现有工作流里,总感觉隔着一层。于是,我就琢磨着,能不能自己动手,从零开始打造一个轻量、可扩展、符合现代工程标准的 Agent CLI 工具?这不仅是学习 Agent 内部运作机制的好机会,更能打造一个完全贴合自己需求的“瑞士军刀”。

这个系列文章,就是记录我如何一步步把这个想法落地的全过程。第一期,我们不谈复杂的 Agent 逻辑,先打好地基——做好项目初始化与工程基建。这听起来可能有点枯燥,但相信我,一个扎实的工程基础,能让你在后续开发中避免无数“坑”,跑得更快、更稳。我们将使用 TypeScript 作为开发语言,配合一系列现代前端/Node.js 工具链,目标是建立一个代码质量高、测试完备、构建流程清晰的 CLI 项目骨架。无论你是想学习如何构建一个专业的 CLI 工具,还是对 Agent 开发感兴趣,希望从工程化角度入手,这篇文章都能给你提供一份可直接复用的“脚手架”方案。

2. 技术栈选型与核心思路拆解

在动手写代码之前,我们先得把“用什么”和“为什么用”想清楚。技术选型不是堆砌时髦名词,而是为项目目标服务。

2.1 核心语言:为什么是 TypeScript?

对于 CLI 工具,尤其是涉及复杂逻辑和未来可能集成 AI 能力的 Agent 来说,JavaScript 的动态类型特性在项目规模增长后会成为维护的噩梦。TypeScript 提供了静态类型检查,能在编码阶段就捕获大量潜在错误(比如拼写错误、参数类型不匹配),这对于提高代码质量和开发体验至关重要。此外,现代 Node.js 生态中许多优秀的库(如 commander , inquirer , ora 等)都提供了良好的 TypeScript 类型定义,集成起来非常顺畅。从长远看,TypeScript 带来的类型安全和智能提示,其收益远大于初期学习曲线带来的成本。

2.2 构建与打包工具:ESBuild 与 Tsup 的权衡

CLI 工具最终需要打包成单个可执行文件(或少量文件)以便分发。常见的打包工具有 Webpack、Rollup、ESBuild 和 Tsup。

  • Webpack/Rollup :功能强大,插件生态丰富,但配置相对复杂,对于 CLI 这种相对简单的场景有些“杀鸡用牛刀”。
  • ESBuild :用 Go 编写,速度极快,但原生对 TypeScript 的处理仅限于转译(Transpile),不进行类型检查,且插件生态较新。
  • Tsup :基于 ESBuild,专为 TypeScript 库和 CLI 工具设计。它“开箱即用”,零配置就能打包 TypeScript,支持生成多种格式(CJS, ESM),并且能自动处理依赖。对于我们的项目, Tsup 是一个更省心、更专注的选择。

2.3 代码质量守护:ESLint & Prettier

团队协作和个人项目的代码风格统一都离不开它们。

  • ESLint :负责代码质量检查,发现潜在的错误、不好的代码习惯。我们可以使用像 @typescript-eslint 这样的插件来获得针对 TypeScript 的规则。
  • Prettier :负责代码格式化,自动将代码整理成统一的风格(缩进、分号、引号等)。让 ESLint 专注于逻辑问题,Prettier 专注于样式问题,二者通过插件配合,可以打造非常流畅的编码体验。

2.4 测试框架:Vitest 的崛起

测试是工程基建不可或缺的一环。Jest 曾是主流,但 Vitest 凭借其与 Vite 生态的完美融合、极快的速度和对 ESM 的原生支持,正在成为许多新项目的首选。它兼容大部分 Jest 的 API,学习成本低,而且热更新速度飞快,特别适合在开发过程中进行 TDD(测试驱动开发)。对于我们的 CLI 工具,需要测试命令解析、函数逻辑、可能有的网络请求模拟等,Vitest 都能很好地胜任。

2.5 项目结构与 Monorepo 考量

我们计划采用一个清晰的项目结构。虽然初期可能只是一个简单的 CLI,但考虑到 Agent 功能模块可能会逐渐增多(例如,不同的工具集、模型接入层、记忆模块等),采用一种易于扩展的结构是明智的。我们不会一开始就上复杂的 Monorepo 工具(如 Turborepo, Nx),但会在目录结构上为未来的模块化留出空间,例如将核心的 Agent 逻辑、工具函数、CLI 命令控制器等分目录存放。

核心思路总结 :我们的目标是建立一个 “类型安全 + 高效构建 + 代码规范 + 测试覆盖” 的现代 TypeScript CLI 项目基底。所有工具的选择都围绕提升开发效率、保障代码质量和简化部署流程展开。

3. 从零开始的工程基建实操

理论说完,我们开始动手。请确保你的系统已经安装了 Node.js(建议 LTS 版本,如 18.x 或 20.x)和 npm/yarn/pnpm 包管理器。我个人推荐使用 pnpm ,因为它更快、更节省磁盘空间。

3.1 初始化项目与基础配置

首先,创建一个新的项目目录并初始化。

mkdir my-agent-cli && cd my-agent-cli
pnpm init

这会生成一个 package.json 文件。接下来,我们安装 TypeScript 作为开发依赖,并初始化 TS 配置。

pnpm add -D typescript @types/node
npx tsc --init

生成的 tsconfig.json 需要根据 CLI 工具的特点进行调整。下面是一个推荐的配置:

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "ESNext",
    "lib": ["ES2022"],
    "moduleResolution": "node",
    "outDir": "./dist",
    "rootDir": "./src",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "forceConsistentCasingInFileNames": true,
    "resolveJsonModule": true,
    "declaration": true, // 生成 .d.ts 类型声明文件
    "declarationDir": "./dist/types",
    "sourceMap": true // 方便调试
  },
  "include": ["src/**/*"],
  "exclude": ["node_modules", "dist", "**/*.test.ts", "**/*.spec.ts"]
}

关键点解析

  • "target": "ES2022" :使用较新的 ECMAScript 标准,以获得更好的性能和语言特性支持。
  • "module": "ESNext" "moduleResolution": "node" :配置为 Node.js 环境下的 ES 模块解析。
  • "outDir" "rootDir" :明确源代码和输出目录。
  • "declaration": true :为你的库生成类型声明文件,如果未来其他项目引用你的核心逻辑会非常有用。
  • "sourceMap": true :打包后,如果运行出错,错误栈能映射回源代码位置,便于调试。

3.2 集成 ESLint 与 Prettier

安装必要的依赖:

pnpm add -D eslint prettier @typescript-eslint/parser @typescript-eslint/eslint-plugin eslint-config-prettier eslint-plugin-prettier
  • @typescript-eslint/parser :ESLint 解析 TypeScript 的解析器。
  • @typescript-eslint/eslint-plugin :提供 TypeScript 相关的 ESLint 规则。
  • eslint-config-prettier :关闭所有与 Prettier 冲突的 ESLint 规则。
  • eslint-plugin-prettier :将 Prettier 作为 ESLint 规则来运行。

创建 .eslintrc.cjs 配置文件(使用 .cjs 扩展名确保在 ESM 项目中也能被正确识别为 CommonJS 模块):

module.exports = {
  parser: '@typescript-eslint/parser',
  parserOptions: {
    ecmaVersion: 'latest',
    sourceType: 'module',
    project: './tsconfig.json',
  },
  plugins: ['@typescript-eslint', 'prettier'],
  extends: [
    'eslint:recommended',
    'plugin:@typescript-eslint/recommended-type-checked', // 使用类型检查的推荐规则
    'plugin:@typescript-eslint/stylistic-type-checked',
    'plugin:prettier/recommended', // 必须放在最后,用于覆盖冲突的格式规则
  ],
  root: true,
  env: {
    node: true,
    es2022: true,
  },
  ignorePatterns: ['dist', 'node_modules', '*.cjs', '*.mjs'],
  rules: {
    // 可以在这里添加或覆盖自定义规则
    '@typescript-eslint/no-unused-vars': ['error', { 'argsIgnorePattern': '^_' }], // 允许以下划线开头的参数未使用
    'prettier/prettier': 'error', // 将 Prettier 问题标记为错误
  },
};

创建 .prettierrc 配置文件,定义你喜欢的代码风格:

{
  "semi": true,
  "trailingComma": "es5",
  "singleQuote": true,
  "printWidth": 100,
  "tabWidth": 2,
  "endOfLine": "lf"
}

最后,在 package.json 中添加脚本:

{
  "scripts": {
    "lint": "eslint src --ext .ts",
    "lint:fix": "eslint src --ext .ts --fix",
    "format": "prettier --write \"src/**/*.ts\"",
    "format:check": "prettier --check \"src/**/*.ts\""
  }
}

现在,你可以运行 pnpm run lint 检查代码问题, pnpm run lint:fix 尝试自动修复, pnpm run format 格式化所有代码。

实操心得 :配置 ESLint 时,很容易遇到解析错误。如果遇到类似“ Parsing error: Cannot read file 'tsconfig.json' ”的错误,请检查 parserOptions.project 的路径是否正确,以及 tsconfig.json 文件是否存在。另外,确保你的 .eslintrc.cjs 文件位于项目根目录。

3.3 配置 Vitest 进行单元测试

安装 Vitest 和相关工具:

pnpm add -D vitest @vitest/ui happy-dom
  • @vitest/ui :提供一个漂亮的浏览器界面来查看和运行测试。
  • happy-dom :一个轻量级的浏览器环境模拟器,如果你需要测试一些涉及 DOM 的代码(虽然 CLI 中不常见),可以用它。这里我们先装上备用。

创建 vitest.config.ts 配置文件:

import { defineConfig } from 'vitest/config';

export default defineConfig({
  test: {
    globals: true, // 类似 Jest,可以使用 describe, it, expect 等全局 API
    environment: 'node', // 测试环境设为 Node.js
    include: ['src/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts}'], // 测试文件匹配模式
    coverage: {
      provider: 'v8', // 使用 V8 的内置覆盖率工具
      reporter: ['text', 'json', 'html'], // 生成多种格式的覆盖率报告
      exclude: ['**/node_modules/**', '**/dist/**', '**/*.config.*'], // 排除目录
    },
  },
});

package.json 中添加测试脚本:

{
  "scripts": {
    "test": "vitest",
    "test:run": "vitest run",
    "test:ui": "vitest --ui",
    "test:coverage": "vitest run --coverage"
  }
}

现在,创建一个简单的测试文件来验证配置。在 src/utils/math.test.ts 中:

import { describe, it, expect } from 'vitest';
import { add } from './math'; // 假设我们有一个 math.ts

describe('math utilities', () => {
  it('should add two numbers correctly', () => {
    expect(add(1, 2)).toBe(3);
    expect(add(-1, 5)).toBe(4);
  });
});

对应的 src/utils/math.ts

export function add(a: number, b: number): number {
  return a + b;
}

运行 pnpm run test ,你会看到测试通过。运行 pnpm run test:ui 可以打开浏览器界面进行交互式测试。

3.4 使用 Tsup 进行构建与打包

安装 Tsup:

pnpm add -D tsup

package.json 中配置构建脚本,并修改 main , module , types 等字段指向构建产物:

{
  "main": "./dist/index.cjs",
  "module": "./dist/index.mjs",
  "types": "./dist/types/index.d.ts",
  "exports": {
    ".": {
      "import": "./dist/index.mjs",
      "require": "./dist/index.cjs",
      "types": "./dist/types/index.d.ts"
    }
  },
  "scripts": {
    "build": "tsup",
    "build:watch": "tsup --watch"
  }
}

创建 tsup.config.ts 文件进行更详细的构建配置:

import { defineConfig } from 'tsup';

export default defineConfig({
  entry: ['src/index.ts'], // 入口文件
  format: ['cjs', 'esm'], // 生成 CommonJS 和 ES Module 两种格式
  dts: true, // 生成类型声明文件 .d.ts
  sourcemap: true, // 生成 sourcemap
  clean: true, // 每次构建前清理 dist 目录
  minify: process.env.NODE_ENV === 'production', // 生产环境压缩
  outDir: 'dist',
  // 如果需要排除某些依赖,使其成为外部依赖(不打包进bundle)
  // external: ['commander', 'openai'],
  // 如果需要为 CLI 生成 shebang,可以配置 banner
  // banner: { js: '#!/usr/bin/env node\n' },
});

注意事项 :对于 CLI 工具,通常我们最终希望用户通过 npm install -g my-agent-cli 安装,然后直接运行 my-agent-cli 命令。这需要在 package.json 中配置 "bin" 字段,并确保入口文件顶部有 #!/usr/bin/env node (shebang)。我们会在下一篇文章具体实现 CLI 命令时详细配置。Tsup 的 banner 选项可以帮我们自动添加 shebang。

现在运行 pnpm run build ,你会在 dist 目录下看到生成的文件: index.cjs , index.mjs , 以及 types 文件夹下的类型声明文件。

3.5 完善开发脚本与 Git 钩子

为了提高团队协作效率和代码提交质量,我们集成 husky lint-staged ,在提交代码前自动运行代码检查和格式化。

pnpm add -D husky lint-staged

初始化 husky:

npx husky init

这会在项目根目录创建 .husky 文件夹,并添加 pre-commit 钩子示例。修改 .husky/pre-commit 文件内容为:

#!/usr/bin/env sh
. "$(dirname -- "$0")/_/husky.sh"

npx lint-staged

然后在 package.json 中配置 lint-staged

{
  "lint-staged": {
    "src/**/*.ts": [
      "prettier --write",
      "eslint --fix"
    ]
  }
}

这样,每次执行 git commit 时, lint-staged 会自动对暂存区(staged)的 TypeScript 文件运行 Prettier 格式化和 ESLint 修复,确保提交的代码符合规范。

最后,我们完善 package.json 中的脚本部分,形成一个完整的工作流:

{
  "scripts": {
    "dev": "tsup --watch", // 开发模式,监听文件变化并构建
    "build": "tsup",
    "lint": "eslint src --ext .ts",
    "lint:fix": "eslint src --ext .ts --fix",
    "format": "prettier --write \"src/**/*.ts\"",
    "format:check": "prettier --check \"src/**/*.ts\"",
    "test": "vitest",
    "test:run": "vitest run",
    "test:ui": "vitest --ui",
    "test:coverage": "vitest run --coverage",
    "prepublishOnly": "npm run build", // 在 npm publish 前自动构建
    "prepare": "husky install" // 在 npm install 后自动安装 husky 钩子
  }
}

4. 目录结构设计与模块化思考

一个清晰的目录结构是项目可维护性的基础。以下是我为这个 Agent CLI 项目设计的初始结构:

my-agent-cli/
├── .husky/                 # Git 钩子
├── dist/                   # 构建输出目录(.gitignore)
├── src/
│   ├── cli/               # CLI 命令入口和命令定义
│   │   ├── index.ts       # CLI 主入口(注册命令)
│   │   └── commands/      # 各个子命令的实现
│   │       ├── init.ts    # 例如:初始化 Agent 配置的命令
│   │       └── run.ts     # 运行 Agent 的命令
│   ├── core/              # Agent 核心逻辑
│   │   ├── agent/         # Agent 主循环、推理逻辑
│   │   ├── memory/        # 记忆模块(如对话历史存储)
│   │   ├── tools/         # Agent 可用的工具集(如搜索、计算、文件操作)
│   │   └── llm/           # 大语言模型客户端封装(如 OpenAI, Anthropic)
│   ├── utils/             # 通用工具函数
│   │   ├── logger.ts      # 日志工具
│   │   ├── config.ts      # 配置文件读写
│   │   └── validator.ts   # 数据验证
│   ├── types/             # 全局类型定义
│   │   └── index.ts
│   └── index.ts           # 库模式的主出口(如果需要被其他模块引用)
├── tests/                 # 集成测试或 E2E 测试(可选,单元测试通常与源码放一起)
├── .eslintrc.cjs
├── .prettierrc
├── tsconfig.json
├── tsup.config.ts
├── vitest.config.ts
├── package.json
└── README.md

设计思路

  • src/cli/ :专注于命令行交互。使用像 commander yargs 这样的库来解析参数、定义命令和子命令。这部分应该尽可能薄,主要调用 core/ 中的逻辑。
  • src/core/ :这是项目的核心,包含 Agent 的所有业务逻辑。严格按功能模块划分,便于独立开发、测试和替换。例如,更换另一个 LLM 提供商,只需修改 llm/ 下的代码。
  • src/utils/ src/types/ :存放共享的辅助代码和类型定义,避免循环依赖。
  • 测试文件 :我倾向于将单元测试文件( .test.ts .spec.ts )放在与其测试的源码文件相邻的位置。例如, src/utils/logger.ts 的测试文件就是 src/utils/logger.test.ts 。这样在查看源码时,能很容易地找到对应的测试。

这种结构为未来功能扩展留下了充足空间。当 tools/ 目录下工具越来越多,或者需要支持多种 memory 后端时,模块化的优势就会体现出来。

5. 常见问题与排查技巧实录

在搭建这套基建的过程中,你可能会遇到一些典型问题。这里记录了我踩过的坑和解决方法。

5.1 TypeScript 配置与模块解析问题

问题 :在导入模块时,VS Code 或 TypeScript 编译器报错“找不到模块”或“没有默认导出”。 排查

  1. 检查 tsconfig.json :确保 "moduleResolution": "node" 已设置。检查 "include" 字段是否包含了你的 src 目录。
  2. 检查包类型 :如果你导入的是一个 CommonJS 包(在 package.json 中没有 "type": "module" ),而你的 tsconfig.json "module" 设置为 "ESNext" ,可能需要设置 "esModuleInterop": true 来兼容。
  3. 路径别名 :如果项目复杂,可能会配置路径别名(如 @/* )。确保 tsconfig.json 中的 "paths" 和打包工具(Tsup)中的别名配置一致。Tsup 可以通过 --alias 标志或在配置文件中配置。

5.2 ESLint 与 Prettier 规则冲突

问题 :保存文件时,ESLint 和 Prettier 的自动修复互相“打架”,导致格式来回变化。 解决 :这是配置顺序问题。务必确保在 .eslintrc.cjs extends 数组中, 'plugin:prettier/recommended' 放在 最后 。这个配置集成了 eslint-config-prettier eslint-plugin-prettier ,能正确关闭冲突的规则并将 Prettier 作为规则运行。

5.3 Vitest 测试中遇到全局 API 未定义

问题 :在测试文件中使用 describe , it , expect 时,TypeScript 报错“找不到名称”。 解决 :有两种方案:

  1. vitest.config.ts 中设置 test.globals: true (我们之前已经做了)。这样 Vitest 会将这些 API 注入全局环境,无需在每个文件导入。同时,你需要在 tsconfig.json "types" 数组中添加 "vitest/globals"
    {
      "compilerOptions": {
        // ...
        "types": ["node", "vitest/globals"]
      }
    }
    
  2. 不启用 globals ,而是在每个测试文件顶部手动导入:
    import { describe, it, expect } from 'vitest';
    
    我更喜欢第一种方式,因为它更接近 Jest 的使用习惯,写起来更简洁。

5.4 Tsup 打包后运行出错(如 __dirname 问题)

问题 :在代码中使用了 __dirname __filename 来获取文件路径,打包成 ESM 格式( .mjs )后运行报错,因为这些变量在 ES 模块中未定义。 解决 :在 Node.js 的 ES 模块中,应使用 import.meta.url 来构造文件路径。可以创建一个工具函数来统一处理:

// src/utils/path.ts
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';

export function getDirname(metaUrl: string): string {
  return dirname(fileURLToPath(metaUrl));
}

// 使用方式
const currentDir = getDirname(import.meta.url);
const configPath = join(currentDir, '..', 'config', 'default.json');

对于 CLI 工具,如果确定只打包成 CommonJS( format: ['cjs'] ),可以继续使用 __dirname 。但为了更好的兼容性,建议使用上述方法。

5.5 依赖项处理:Bundle 还是 External?

问题 :使用 Tsup 打包时,不确定是否应该把第三方依赖(如 commander , openai )打包进最终的 bundle 决策指南

  • 打包进去(默认) :生成一个独立的文件,用户无需安装你的依赖。适用于简单工具,希望用户安装即用。但会导致文件体积变大,且如果用户环境中已有相同依赖的不同版本,可能引发冲突。
  • 外部化(External) :不打包,在 package.json 中声明为 dependencies 。用户安装你的 CLI 时会同时安装这些依赖。这是 Node.js CLI 工具的推荐做法 ,因为它符合 Node.js 的模块管理机制,能更好地处理版本依赖和模块缓存。

tsup.config.ts 中,你可以通过 external 选项来指定外部依赖:

export default defineConfig({
  // ...
  external: ['commander', 'openai', 'langchain'], // 这些包不会被打包
});

相应的,你需要确保它们在 package.json dependencies 中。

5.6 Git 钩子(Husky)不生效

问题 :执行 git commit 时,没有触发 lint-staged 排查步骤

  1. 检查 .husky/pre-commit 文件权限 :在 Unix 系统上,需要确保该文件有可执行权限。可以运行 chmod +x .husky/pre-commit
  2. 检查 lint-staged 配置 :确认 package.json 中的 lint-staged 路径模式是否正确匹配你的文件。
  3. 重新安装 Husky :有时钩子链接会失效。可以删除 .husky 目录,然后重新运行 npx husky init npm run prepare (或 pnpm run prepare )。
  4. 检查 Git 版本 :确保 Git 版本 > 2.9。

经过以上步骤,一个具备现代化工程基建的 TypeScript CLI 项目骨架就搭建完毕了。它拥有严格的类型检查、自动化的代码格式化与检查、高效的单元测试、快速的构建打包流程,以及保障提交质量的 Git 钩子。这个坚实的基础,将为我们后续实现具体的 Agent CLI 功能扫清工程上的障碍,让我们可以更专注于业务逻辑的开发。在下一篇文章中,我们将在这个基建之上,引入 commander 库来构建命令行界面,并实现第一个简单的 Agent 交互命令。

更多推荐