10. 测试 + 打包发布——让这个项目成为真正的 npm 包

九篇文章走下来,Agent 运行时核心的所有模块都搭好了。但一个项目能不能交付给别人用,取决于两件事——你敢不敢拍胸脯说"代码没有 bug",以及别人能不能一行命令装上去就用。这一篇我们做测试体系建设和 npm 打包发布。

Summary: 九篇文章走下来,Agent 运行时核心的所有模块都搭好了。但一个项目能不能交付给别人用,取决于两件事——你敢不敢拍胸脯说"代码没有 bug",以及别人能不能一行命令装上去就用。这一篇我们做测试体系建设和 npm 打包发布。


前九篇文章,从接口设计到主循环,从 Tool 系统到消息模型,从记忆模块到流式输出,我们把一个 Agent 运行时核心的骨架和血肉一篇文章一篇文章地搭完了。

但一个项目写完代码只是完成了一半。剩下的一半是:你敢不敢让别人用?

写给自己玩的代码,容忍度很高。但一旦要作为 npm 包发布出去,一旦 README 上说"一行命令安装",标准就不一样了。别人遇到 bug 不会打开你的源码一行一行 debug,人家第一反应是"这个包不靠谱",然后换一个。

第四篇 Message 系统写完的时候,我其实已经能跑了。第六篇 Provider 写完,已经能跑通完整的 Tool Calling 链路了。但我一直没打包。为什么?

因为我知道还有很多坑没踩。工具执行时参数解析失败会报未捕获的异常吗?记忆从 Session 恢复时消息顺序对吗?并行工具调用一个超时了其他会不会被连带取消?这些问题,光靠"手动跑一次看看"是不够的。靠直觉可以写代码,靠直觉不能保证代码可交付。

这一篇做两件事:测试体系建设,把这个项目的质量底线划清楚;npm 打包发布,让这个项目真正可交付。

测试 Agent 运行时和测试普通库不一样

先想清楚要测什么。

如果我在测一个普通的工具函数库——比如 lodash——测试策略很简单:输入 A 期望输出 B。函数式、无状态、纯。

但 Agent 运行时不是这样的。Agent 的核心是一个有状态的循环

LLM 输出 → 解析工具调用 → 执行工具 → 将结果送回 LLM → LLM 再输出 → ...

每一步都有状态:Agent 当前在第几轮迭代、消息列表有多长、工具执行结果是否被正确地作为 tool message 加入列表。而且 Agent 依赖 LLM——一个不可控的、非确定性的外部系统。

这就带来了三个测试挑战。

第一,Agent 依赖 LLM,但测试不能真的调 LLM。真实的 LLM 调用不仅慢、贵、还有网络波动。一次 npm test 不应该花五分钟,也不应该因为 OpenAI 的 API 挂了就全盘变红。

第二,Agent 的测试覆盖需要验证完整循环。单独测"消息校验"、"工具注册"、"并行执行"这些模块是基础,但真正的 bug 往往出现在模块之间的交互上。比如:并行执行器返回了结果,但 Agent 没有正确地把结果拼回消息列表。

第三个挑战是状态隔离。每次 run() 之间不能互相污染。Agent 的短期记忆、Session 的消息列表、LongTermMemory 的搜索上下文——这些都是全局状态,测试之间必须彻底隔离,否则一个测试的副作用会影响到下一个。

这三个挑战对应了三个层级的测试策略。

单元测试,测每个模块内部的逻辑是否正确。不依赖外部系统,纯逻辑验证。比如 DefaultToolRegistry.register() 能不能正确注册、validateMessage() 能不能检出无效消息。

集成测试,测模块之间的交互是否正确。Mock 掉 LLM Provider(用预定义的响应序列代替真实的 API 调用),这样就能被测 Tool Calling 循环的正确性:LLM 返回 tool_calls → 工具被执行 → 结果被送回消息列表。

端到端测试项目,部署到真实环境跑一轮。这个交给用户自己去跑,不在 CI 里做。

我们的项目中,单元测试放在每个模块的 __tests__ 目录里,集成测试集中在 tests/ 根目录下。这个划分不是随意的——__tests__ 里的测试只依赖本模块的代码,tests/ 里的测试可能跨模块引用。

现有的测试,一张图看明白

先看下当前项目的测试文件分布:

src/
├── core/__tests__/
│   └── message.test.ts          # 消息工厂、校验、Token估算、窗口裁剪
├── memory/__tests__/
│   └── memory.test.ts           # 长短期记忆、合并策略、自动合并
└── tests/
    ├── provider/
    │   ├── response-parser.test.ts   # JSON修复、工具调用解析
    │   └── schema-builder.test.ts    # Schema转换、缓存
    ├── runloop/
    │   └── agent.spec.ts        # DefaultAgent 主循环、流式输出、Hooks
    └── tools/
        └── parallel-executor.spec.ts # Semaphore、冲突检测、分层并行

一共 6 个测试文件,覆盖了项目核心的 5 个模块。

message.test.ts 是单元测试的典型代表。它不依赖任何外部模块——没有 Agent、没有 Provider、没有 Tool。就是构造消息对象、校验、估算 Token、裁剪窗口。每个测试用例都是"输入 A → 期望 B"的纯函数式验证。114 行测试代码覆盖了整个消息模块的核心功能。

agent.spec.ts 则是集成测试的代表。它通过 createMockProvider 构造了一个假的 LLM——不是 Mock 框架生成的那种空壳,而是能返回预定义响应序列的"模拟 LLM":

function createMockProvider(responses: LLMResponse[]): LLMProvider {
  let index = 0;
  return {
    name: 'mock',
    async complete(): Promise<LLMResponse> {
      if (index >= responses.length) {
        return { content: 'No more responses', finishReason: 'stop' };
      }
      return responses[index++];
    },
    async completeStream(...) { ... },
  };
}

关键设计是 index 的自增。每次调用 complete(),返回的响应不同。第一次返回 tool_calls 响应,第二次返回最终回答。这模拟了"LLM 先决定调用工具,拿到结果后再给出最终回答"的完整流程。

测试用例写法:

it('应该支持一次工具调用并返回最终结果', async () => {
  const provider = createMockProvider([
    // 第一轮:LLM 决定调工具
    {
      content: '',
      finishReason: 'tool_calls',
      toolCalls: [
        { id: 'call_1', name: 'calculator', args: { expression: '1+2' } },
      ],
    },
    // 第二轮:LLM 给出最终回答
    {
      content: 'The result of 1 + 2 is 3.',
      finishReason: 'stop',
    },
  ]);
  const agent = createTestAgent({ provider, tools: [calculatorTool] });
  const result = await agent.run('What is 1+2?');

  expect(result.output).toBe('The result of 1 + 2 is 3.');
  expect(result.iterations).toBe(2);
  expect(result.stoppedEarly).toBe(false);
});

这个测试只用了两次 complete() 调用——20 毫秒就跑完了。如果改成调真的 GPT-4o,同样的测试逻辑至少需要 2 秒。而这里我们验证了核心行为:输入问题 → Agent 调用工具 → 拿到结果 → 给出最终回答。

整个 agent.spec.ts 文件有 22 个 test cases,覆盖了 Agent 主循环的方方面面:直接回答、单次工具调用、多次工具调用、maxIterations 限制、工具不存在、工具抛出异常、记忆系统集成、Hooks 触发、以及流式输出的事件顺序验证。

我们还差什么

6 个测试文件、几十个 test cases,看起来不少了。但仔细审视,还有几个缺口。

缺少消息模块的核心 Message 类型测试message.test.ts 覆盖了工厂函数和校验,但 TokenEstimatorMessageWindow 的边界情况还有欠缺——比如空消息列表、超长消息、工具调用标记在窗口裁剪后是否完整。这部分在当前的测试快照中已有框架级覆盖,但边界测试可以更细。我会在未来的迭代中补充,这里先集中精力解决 Session 持久化和 CI 两个最紧迫的缺口。

缺少 Session 持久化测试FileSession 的序列化/反序列化、FileCheckpointManager 的检查点保存和恢复——这些涉及文件 I/O 的逻辑最容易出 bug,还没有自动化覆盖。

缺少 CI 配置。现在跑测试靠手动执行 npm test。推代码之前靠记忆,万一忘了跑,测试就形同虚设。

我来补上这几个缺口。然后配置 npm 打包。

补上 Session 持久化测试

Session 模块有两个核心能力:将对话序列化到文件、从文件恢复。测试的策略是:用临时目录模拟文件系统,测试写盘和读盘的正确性。

// tests/session/persistent-session.test.ts
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { mkdtempSync, rmSync, existsSync, readFileSync } from 'node:fs';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import {
  FileSession,
  FileCheckpointManager,
} from '../../src/core/session.js';
import { userMessage, assistantMessage } from '../../src/core/message.js';

describe('FileSession', () => {
  let tmpDir: string;

  beforeEach(() => {
    tmpDir = mkdtempSync(join(tmpdir(), 'session-test-'));
  });

  afterEach(() => {
    rmSync(tmpDir, { recursive: true, force: true });
  });

  it('新会话应该创建空文件', async () => {
    const session = new FileSession({ storageDir: tmpDir });
    expect(session.getMessages()).toHaveLength(0);
  });

  it('添加消息后文件应被写入', async () => {
    const session = new FileSession({ storageDir: tmpDir });
    session.addMessage(userMessage('Hello'));
    session.addMessage(assistantMessage('Hi there!'));

    // 等待异步写盘完成
    await new Promise((r) => setTimeout(r, 100));

    expect(existsSync(session.storagePath)).toBe(true);
    const content = readFileSync(session.storagePath, 'utf-8');
    const parsed = JSON.parse(content);
    expect(parsed.messages).toHaveLength(2);
  });

  it('从文件恢复应该得到完整的消息列表', async () => {
    const session1 = new FileSession({ storageDir: tmpDir });
    session1.addMessage(userMessage('Hello'));
    await new Promise((r) => setTimeout(r, 100));

    // 必须用同一个 session id 才能恢复
    // 不传 id 会生成新的 UUID,load 自然找不到已有文件
    const session2 = new FileSession({
      id: session1.id,
      storageDir: tmpDir,
    });
    await session2.load();
    expect(session2.getMessages()).toHaveLength(1);
    expect(session2.getMessages()[0].content).toBe('Hello');
  });
});

这里有一个细节值得注意:afterEach 中的 rmSync 使用 { recursive: true, force: true }。测试临时目录必须彻底清理,否则一次失败的测试留下的脏数据会污染下一次测试。force: true 保证即使目录已被清理也不会抛出异常。

FileCheckpointManager 的测试逻辑类似——但多了检查点列表排序和删除的验证:

describe('FileCheckpointManager', () => {
  it('保存和加载检查点应该一致', async () => {
    const tmpDir = mkdtempSync(join(tmpdir(), 'ckpt-test-'));
    try {
      const mgr = new FileCheckpointManager(tmpDir);
      const sessionId = 'test-session';

      const ckpt: SessionCheckpoint = {
        id: 'ckpt-1',
        timestamp: Date.now(),
        messages: [userMessage('Hi')],
        metadata: {},
      };

      await mgr.saveCheckpoint(sessionId, ckpt);
      const loaded = await mgr.loadCheckpoint(sessionId, 'ckpt-1');
      expect(loaded).not.toBeNull();
      expect(loaded!.messages).toHaveLength(1);
      expect(loaded!.messages[0].content).toBe('Hi');
    } finally {
      rmSync(tmpDir, { recursive: true, force: true });
    }
  });
});

CI 配置

测试写好了,但如果没有 CI,测试就只在本地跑。推代码之前全靠人的意志力——而意志力是最不可靠的。

GitHub Actions 配置如下:

# .github/workflows/ci.yml
name: CI

on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

jobs:
  test:
    runs-on: ubuntu-latest

    strategy:
      matrix:
        node-version: [18, 20, 22]

    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: ${{ matrix.node-version }}
          cache: 'npm'

      - run: npm ci
      - run: npm run build
      - run: npm test

亮点是 matrix 策略。我们的项目声明了 "type": "module",用 ESM 格式。Node.js 17 以下对 ESM 的支持有过一些 breaking change(比如 JSON imports 模块需要 assert vs with 关键字)。在 18、20、22 三个 Node 版本上跑测试,既保证了兼容性,也不会让 CI 跑太久(三个版本并行执行)。

npm cinpm install 的区别:npm ci 严格依据 package-lock.json 安装依赖,不修改 package-lock.json。在 CI 环境中始终用 npm ci,保证每次安装结果一致。

配置 npm 包

测试通过之后,下一个问题是:别人怎么用?

我们来看当前的 package.json,哪些已经对了,哪些还需要调整:

{
  "name": "ts-agent-core",
  "version": "1.0.0",
  "description": "从零搭一个 TypeScript Agent 运行时核心",
  "type": "module",
  "main": "./dist/index.js",
  "types": "./dist/index.d.ts",
  "exports": {
    ".": "./dist/index.js",
    "./*": "./dist/*.js"
  },
  "scripts": {
    "build": "tsc",
    "test": "vitest run",
    "test:watch": "vitest",
    "prepublishOnly": "npm run build"
  },
  "devDependencies": { ... },
  "dependencies": { "zod": "^4.4.3" }
}

exports 字段是当前最重要的配置。它定义了这个包哪些模块可以被外部引用。".": "./dist/index.js" 表示 import { Agent } from 'ts-agent-core' 指向 dist/index.js

子模块怎么映射?一个常见的写法是 "./*": "./dist/*.js" 通配符模式。但在这个项目里它行不通。因为 TSC 在 rootDir: ./src 下把 src/tools/index.ts 编译为 dist/tools/index.js,而不是 dist/tools.js。通配符把 ts-agent-core/tools 映射到 dist/tools.js——文件不存在。

正确的做法是用显式子路径导出,每个子模块一个独立入口:

{
  "exports": {
    ".": "./dist/index.js",
    "./tools": "./dist/tools/index.js",
    "./runloop": "./dist/runloop/index.js",
    "./provider": "./dist/provider/index.js",
    "./memory": "./dist/memory/index.js",
    "./core": "./dist/core/index.js"
  }
}

这样 import { calculatorTool } from 'ts-agent-core/tools' 正确指向 dist/tools/index.js。而且每个子模块只有经过我们显式声明的才能被引用——如果有人想 import { internalFunction } from 'ts-agent-core/core/some-internal',TypeScript 直接报错。

main 字段也要和 exports 保持一致:

{
  "main": "./dist/index.js",
  "types": "./dist/index.d.ts"
}

但当前还缺少几个关键字段。

files 字段控制哪些文件被包含在 npm 包中。默认 npm 会包含所有文件(除了 .gitignore 中列出的),意味着 tests/src/node_modules/ 都会被上传到 npm 仓库。这没必要,还浪费带宽。显式声明:

{
  "files": ["dist", "README.md", "LICENSE"]
}

这样 npm 包只包含编译后的 JS、类型声明、README 和 License。

license 字段声明许可证类型。开源项目通常用 MIT:

{
  "license": "MIT"
}

keywordsrepository帮助其他开发者在 npm 上找到这个包:

{
  "keywords": ["agent", "llm", "typescript", "tool-calling", "ai"],
  "repository": {
    "type": "git",
    "url": "git+https://github.com/yourname/ts-agent-core.git"
  }
}

engines 字段声明支持的 Node.js 版本,防止使用者在旧版本 Node 上安装:

{
  "engines": {
    "node": ">=18"
  }
}

sideEffects 字段告诉打包工具(webpack、Rollup)这个包是否有副作用。声明 false 允许打包工具对未使用的导出做 tree-shaking——把用不到的模块从产物中删掉。如果你的项目只是用 import { Tool } from 'ts-agent-core/core' 取个类型定义,打包工具看到 sideEffects: false 就知道不需要把整个 tools/ 目录都打包进去:

{
  "sideEffects": false
}

files vs .npmignore 的选择files 是白名单机制——只包含列出的路径。.npmignore 是黑名单机制——排除列出的路径。白名单更安全:你在项目中新增一个文件,它不会被自动发布。所以我选了 files,而不是 .npmignore

加上之后,完整的 package.json 核心字段:

{
  "name": "ts-agent-core",
  "version": "1.0.0",
  "description": "从零搭一个 TypeScript Agent 运行时核心",
  "type": "module",
  "main": "./dist/index.js",
  "types": "./dist/index.d.ts",
  "exports": {
    ".": "./dist/index.js",
    "./tools": "./dist/tools/index.js",
    "./runloop": "./dist/runloop/index.js",
    "./provider": "./dist/provider/index.js",
    "./memory": "./dist/memory/index.js",
    "./core": "./dist/core/index.js"
  },
  "sideEffects": false,
  "files": ["dist", "README.md", "LICENSE"],
  "license": "MIT",
  "keywords": ["agent", "llm", "typescript", "tool-calling", "ai"],
  "repository": {
    "type": "git",
    "url": "git+https://github.com/yourname/ts-agent-core.git"
  },
  "engines": {
    "node": ">=18"
  },
  "scripts": {
    "build": "tsc",
    "test": "vitest run",
    "test:watch": "vitest",
    "prepublishOnly": "npm run build"
  }
}

tsconfig 的调整

当前的 tsconfig.json 对于开发来说没有问题,但作为 npm 包需要做一点调整:

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "Node16",
    "moduleResolution": "Node16",
    "lib": ["ES2022"],
    "outDir": "./dist",
    "rootDir": "./src",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "forceConsistentCasingInFileNames": true,
    "declaration": true,
    "declarationMap": true,
    "sourceMap": true
  },
  "include": ["src/**/*"],
  "exclude": ["node_modules", "dist", "tests", "**/__tests__/**"]
}

关键变化在 exclude 中增加了 "**/__tests__/**"。现有的测试文件嵌在 src/ 目录里(src/core/__tests__/src/memory/__tests__/)。如果不排除,TypeScript 在编译时会尝试编译测试文件。虽然不会报错(测试代码引用的 vitest 类型是 devDependencies,TSC 不会报错),但会增加编译时间,而且测试代码会被复制到 dist/ 目录。

include 中只包含 src/**/* 的情况下,其实 __tests__ 里的文件不会被编译进 dist/——因为编译入口从 src/index.ts 出发,而 __tests__/ 中的文件没有被 src/**/* 中的 index.ts import。但显式排除更安全。

发布流程

配置都好了,发布就是一条命令的事:

# 确保代码是最新的
git push origin main

# 触发 CI
# 等 GitHub Actions 跑完,所有测试通过

# 发布到 npm
npm publish --access public

--access public 因为作用域包(@yourname/ts-agent-core)默认是私有的,非作用域包可以省略这个参数。

每次发布前 prepublishOnly 脚本会自动执行 npm run build,确保发布的是最新的编译结果。

这里用 prepublishOnly 而不是 prepare 是有考量的。preparenpm publishnpm install(从 git 安装时)都会执行。如果你把构建脚本放在 prepare 里,别人从 GitHub 直接 clone 你的仓库然后 npm install,也会触发构建。这个行为在某些场景下是需要的(比如没有发布到 npm 的 monorepo),但对于一个标准的 npm 包来说,prepublishOnly 更干净——只在上传前执行。

prepublishOnly 是 npm 的生命周期钩子之一。其他还有 prepack(打包前)、postpublish(发布后)等。

如果想把发布流程自动化,可以用 nprelease-it 这样的发布辅助工具,它们会自动处理:版本号提升、Git tag 创建、CHANGELOG 生成、发布。

不过对于这个项目,我更推荐手动走两步:

# 第一步:更新版本号
npm version patch  # 或者 minor / major

# 第二步:发布(prepublishOnly 会自动 build)
npm publish

npm version 会自动做三件事:更新 package.json 的 version 字段、创建 Git commit、创建 Git tag。然后 npm publish 上传到 npm 仓库。

为什么推荐分开做而不是用一个工具?因为 npm version 的逻辑是透明的——你清楚地知道版本号从 1.0.0 变成了 1.0.1,知道这个 commit 是什么样的,知道 tag 的命名规则是 v1.0.1。但如果你用 np,它在背后做了很多自动化的事情,一旦出了问题,排查起来需要更多时间。

我的原则:在发布流程这种关键路径上,不要藏任何黑盒。

这个项目现在长什么样

十篇文章下来,完整的目录结构:

ts-agent-core/
├── .github/workflows/      # CI 配置
│   └── ci.yml
├── src/
│   ├── core/
│   │   ├── __tests__/
│   │   │   └── message.test.ts
│   │   ├── agent.ts
│   │   ├── index.ts
│   │   ├── llm.ts
│   │   ├── memory.ts
│   │   ├── message.ts
│   │   ├── provider-adapter.ts
│   │   ├── session.ts
│   │   └── tool.ts
│   ├── llm/               # 预留
│   ├── memory/
│   │   ├── __tests__/
│   │   │   └── memory.test.ts
│   │   ├── consolidation.ts
│   │   ├── default-memory-system.ts
│   │   ├── index.ts
│   │   └── long-term-memory.ts
│   ├── provider/
│   │   ├── index.ts
│   │   ├── openai-provider.ts
│   │   ├── response-parser.ts
│   │   └── schema-builder.ts
│   ├── runloop/
│   │   ├── agent.ts
│   │   └── index.ts
│   ├── tools/
│   │   ├── calculator.ts
│   │   ├── file-reader.ts
│   │   ├── index.ts
│   │   └── parallel-executor.ts
│   └── index.ts
├── tests/
│   ├── provider/
│   │   ├── response-parser.test.ts
│   │   └── schema-builder.test.ts
│   ├── runloop/
│   │   └── agent.spec.ts
│   ├── session/
│   │   └── persistent-session.test.ts
│   └── tools/
│       └── parallel-executor.spec.ts
├── package.json
├── tsconfig.json
├── .gitignore
└── README.md

这个结构有一个清晰的层次:src/core/ 定义所有接口和基础类型,src/provider/ 实现 LLM 接入,src/runloop/ 实现 Agent 主循环,src/tools/ 实现工具系统,src/memory/ 实现记忆模块。

测试分为两类:嵌入在 src/ 中的单元测试(__tests__/)和独立在 tests/ 中的集成测试。这个划分不是强制的(Vitest 不关心测试文件放哪里),但它传达了一个信息:在 __tests__ 中的测试只测本模块,在 tests/ 中的测试可能跨模块。

回顾一下这十篇做了什么

第一篇做了一件事:画好图。接口先行,模块解耦,把整个 Agent 运行时的架构定下来。

第二篇实现 Agent 主循环——那个 while (iterations < maxIterations) 的核心循环。看起来只有几行代码,但它是整个项目的心脏。

第三篇做 Tool 系统。Tool 接口、ToolRegistry、Zod 参数校验。Tool 是 Agent 与外部世界交互的唯一通道。

第四篇消息模型。四个角色类型:system、user、assistant、tool。校验、Token 估算、窗口裁剪。消息是 Agent 的血液,流过每一个环节。

第五篇记忆模块。短期记忆(滑动窗口)和长期记忆(关键词检索 + 遗忘机制)。

第六篇到第九篇是进阶能力:从 LLM 响应到工具调用的解析链路、并行工具执行与冲突检测、会话持久化与检查点、流式输出。

最后一篇,就是这一篇,做测试体系建设和打包发布。

从零开始,十篇,一个可以运行、可以测试、可以发布的 TypeScript Agent 运行时核心。

但我不会说"十篇就够了"。Agent 运行时这个领域太新了,新到连"运行时应该包含什么"都没有共识。这个项目现在是一个好用、可扩展的起点,但绝不是终点。持久化可以扩展到 Redis 和 MySQL,Tool 系统可以加 MCP 协议支持,记忆模块可以接向量数据库,流式输出可以加 SSE 端点——这些后续的扩展方向,在每一篇文章的接口预留中都能找到对应的钩子。

十篇写完,但路没有走完。


评论

此博客中的热门博文

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