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 覆盖了工厂函数和校验,但 TokenEstimator 和 MessageWindow 的边界情况还有欠缺——比如空消息列表、超长消息、工具调用标记在窗口裁剪后是否完整。这部分在当前的测试快照中已有框架级覆盖,但边界测试可以更细。我会在未来的迭代中补充,这里先集中精力解决 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 ci 和 npm 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"
}
keywords 和 repository帮助其他开发者在 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 是有考量的。prepare 在 npm publish 和 npm install(从 git 安装时)都会执行。如果你把构建脚本放在 prepare 里,别人从 GitHub 直接 clone 你的仓库然后 npm install,也会触发构建。这个行为在某些场景下是需要的(比如没有发布到 npm 的 monorepo),但对于一个标准的 npm 包来说,prepublishOnly 更干净——只在上传前执行。
prepublishOnly 是 npm 的生命周期钩子之一。其他还有 prepack(打包前)、postpublish(发布后)等。
如果想把发布流程自动化,可以用 np 或 release-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 端点——这些后续的扩展方向,在每一篇文章的接口预留中都能找到对应的钩子。
十篇写完,但路没有走完。
评论
发表评论