在一个 Next.js 应用里,当某些模块越来越稳定、越来越可能被复用时,什么时候应该把它们拆成 packages/* 里的内部 workspace 包?
我在 AI Mind v0.0.10 里处理的,就是这样一个问题。
先简单介绍一下这个项目。AI Mind 不是一个一次性做完的 AI 产品,而是一个按版本持续演进的 AI Native Runtime Skeleton。它从本地聊天闭环出发,逐步长出结构化流式协议、Tool Calling、Skill Runtime、MCP 接入,以及后面的 Agent / 数据层能力。

当前主应用在 apps/webapp。到 v0.0.10 为止,这个项目已经能跑一条比较完整的聊天链路:请求从 /api/chat 进入,经过 chat-service 和 runtime 编排,再去衔接 skill、tool、MCP,最后以前端可消费的流式 chunk 返回。
也正因为这条主链已经逐渐跑稳,一个更具体、也更工程化的问题才会冒出来:当某一层能力已经明显稳定、也明显可能复用时,我们到底应该什么时候把它拆成 packages/* 里的内部 workspace 包?
这正是 v0.0.10 的主题。 这一版我没有一上来就把整个 Chat Runtime 抽出去,也没有为了 monorepo 先做一个"大而全"的基础包,而是先在 apps/webapp 内把聊天主链收口,再只把真正稳定的流式内核沉成 @ai-mind/stream-core。
所以这篇文章不会从 pnpm monorepo 的基础配置讲起,也不会把重点放在"我又拆了一个包"。我更想复盘的是一次真实项目里很常见、也很容易做重的工程判断:
- 什么样的代码,才值得先拆成内部 workspace 包?
- 为什么在拆包之前,我们最好先把应用内 Runtime 的边界做稳?
先看结论
拆包不是目标。先把应用内边界收稳,再把已经跑稳的那一小块内核沉淀出来,拆包才会真的带来收益。
apps/webapp 与 packages/stream-core 的结构示意图

1. 为什么这次拆包不是从 package 开始,而是从 Runtime 收口开始
1.1 拆包不是目标,稳定边界才是目标
真正值得优先解决的,不是"怎么拆包",而是"边界是不是已经稳了"。
在工程里,拆包本身并不天然代表结构更好。目录拆得更细,也不等于边界就更清楚。真正关键的是,我们能不能先回答下面这几个问题:
- 这一层的语义是不是已经稳定了?
- 它是不是已经不再强依赖当前应用里的业务编排?
- 如果现在把它抽出去,边界会更清楚,还是只会多一层跳转?
如果这些问题还没想明白,拆包通常不会减少复杂度,只会把复杂度换个目录继续保存。
边界没稳时,抽出去的往往不是"可复用内核",而是一份还在变化中的局部实现。它带来的结果通常也不难想象:
- app 内还得持续频繁修改它
- 对外接口会跟着反复抖动
- 主链职责没有更清楚,反而多了一层跨目录理解成本
所以对我来说,拆包的前提不是"能不能拆",而是"是不是已经稳到值得拆"。
1.2 这个问题是怎么在我的项目里出现的
AI Mind 当前是一个 Next.js + pnpm monorepo 的 AI Webapp,主应用在 apps/webapp。
到这一轮之前,仓库层面的 monorepo 形态其实已经在了,但聊天主链里不少核心逻辑仍然集中在 app 内部。换句话说,目录先搭起来了,Runtime 的边界却还没有完全长清楚。
所以我要解决的,本质上不是"怎么把 monorepo 配起来",而是"在已经存在的 monorepo 里,哪些东西真的成熟到值得沉淀成内部 workspace 包"。
如果只看目录变化,这一版像是做了两件事:
- 把
chat-service拆薄 - 新建了
packages/stream-core
但从工程演进角度看,它们其实是一件事的前后两步:
先把
apps/webapp里的聊天主链收口成"薄 facade + runtime 编排层",再把其中已经稳定的流式内核沉淀成内部 workspace 包。
也正因为先做了前一步,后面"到底什么值得拆"这件事才开始变得清楚。
我最后把这版真正要回答的问题,收成了两个判断:
- 聊天主链内部边界是否已经足够清晰?
- 哪一部分能力已经稳定到值得从 app 内部沉淀成包?
如果这两个问题不先回答,所谓 package 化就很容易退化成"目录迁移",而不是一次真正有价值的结构升级。
2. 第一步:先把应用内 Chat Runtime 收口出来
2.1 为什么 chat-service 不能继续变胖
这版真正先动的,不是 package,而是 chat-service 这个入口层。
在一个聊天应用里,chat-service 很容易不断吸收新职责:
- prompt 构建
- planning / retry / final answer
- tool / resource 执行
- chunk 写出
- 错误收口
短期看这样很方便,因为所有逻辑都能往一个地方放。长期看,它会慢慢变成一个很典型的"胖服务层":
- 外部入口和内部编排耦在一起
- 测试越来越难写
- 边界越来越难拆
所以 v0.0.10 的第一步不是抽包,而是先把这个入口层重新收回到它该有的位置。
2.2 我怎么把聊天主链收口成"薄 facade + runtime 编排层"
我最后把主链收成了一个更容易解释、也更容易继续演进的结构:
text
route
-> chat-service facade
-> runtime
-> skills / tools / mcp
对应实现大致分布在这些位置:
apps/webapp/app/api/chat/route.ts(聊天 API 入口,负责 HTTP 边界和错误映射)apps/webapp/lib/ai/chat-service.ts(聊天服务 facade,负责对外暴露稳定入口和包装响应)apps/webapp/lib/ai/runtime/(聊天运行时编排层,真正组织 planning、tool 调用和最终回答)
chat-service 现在的角色已经很克制了,它不再承接整条链路的所有细节,而是只负责稳定入口和响应包装:
ts
export function createChatService(deps: ChatServiceDependencies) {
return {
async streamChat(request: ChatRequest, context: ChatExecutionContext) {
const streamResult = await createChatStreamResult(request, context, deps)
return new Response(streamResult.body, {
headers: streamResult.headers,
})
},
}
}
这段代码很小,但它表达出来的边界很重要:对外入口留在 facade,真正的运行时编排收回 runtime。
2.3 Runtime 收口后,内部职责怎么重新分配
主链一旦收口,runtime 内部的职责也就开始变清楚了。
当前核心文件主要包括:
chat-session.ts(按请求组装会话上下文、模型实例、active tools 和 system prompts)chat-orchestrator.ts(决定 direct-answer、planning、tool-execution、final-answer 这些阶段怎么串起来)assistant-stream.ts(消费模型输出流,把 reasoning / text 等内容写成标准 chunk)tool-runtime/(承接 tool call 的校验、执行,以及 Tool / Resource 展示信息映射)authoritative-answer.ts(判断单工具确定性结果是否可以跳过模型、直接静态回流)
这一节最重要的,不是把文件列出来,而是让我们能明确看见:谁负责外部入口,谁负责运行时编排,谁负责具体执行。
只有当应用内 Runtime 自己先变清楚了,我们才看得见两件事:
- 什么是真正稳定的内核
- 什么仍然属于当前应用的编排层
这一步做完以后,后面的拆包判断才不再靠感觉,而是可以基于已经清楚的职责边界来做。
3. 第二步:怎么判断哪些代码才算"稳定内核"
3.1 我给自己用的一组拆包判断标准
这次我给自己定的标准很简单,但非常实用:
- 语义是否稳定
- 是否与业务策略弱耦合
- 是否跨层复用明显
- 是否具备独立测试价值
- 是否可以单独
build / typecheck - 是否值得被多个 app / 模块消费
只要前面几条还答不清楚,我通常就不会急着拆。
3.2 适合先拆出去的,不是"最大的一块",而是"最稳定的一块"
这次我很想留下来的一个判断是:先拆出去的,不一定是最大的那块,而应该是最稳定的那块。
很多时候我们天然会盯着最大的模块:
- 最大的 service
- 最大的 runtime
- 最大的 orchestration
但大的东西,往往也是变化最多、业务语义最重的东西。
这次真正适合先拆出去的,反而不是最大块,而是最稳定的一块:
- 流式协议
- 生命周期
- 错误 chunk
- static writers
- NDJSON writer
它们不大,却已经足够清楚、足够独立,也足够值得被当成一层内核看待。
3.3 用项目举例:哪些东西我认为还不该拆
先说我明确不打算在这一步就拆出去的部分。
chat-orchestrator(负责 planning、tool 执行、authoritative answer 和 final answer 的阶段编排)chat-session(负责按当前请求组装模型、messages、skill prompt 和 active tools)tool-runtime(负责 tool call 校验、执行,以及 Tool / Resource 展示信息映射)- Skill 编排(决定当前请求命中哪个 skill,以及这个 skill 允许使用哪些工具)
- MCP 消费层(把外部 MCP Tool / Resource 接到当前 runtime 和展示语义上)
原因很直接:它们仍然带有明显的应用内语义和业务编排特征。
这些模块继续留在 apps/webapp,反而是更清晰的选择。
3.4 用项目举例:哪些东西已经足够稳定
再看另一边。下面这些内容,已经很接近一层可以单独沉淀的稳定内核:
ChatStreamChunk(定义整条流式协议里有哪些 chunk,以及每种 chunk 带什么字段)StreamLifecycle(约束start / finish / runtime error这些生命周期终态只发一次)errorchunk helper (统一生成和写出errorchunk)- static text / reasoning writers(把静态文本或推理内容写成标准流式 part)
- NDJSON web writer(把 chunk 序列编码成前端可消费的 NDJSON 响应体)
它们的共同点也很明显:
- 不直接携带业务策略
- 语义稳定
- 本身就值得独立测试
- 很容易被别的 app 或 service 复用
这就是 stream-core 最终被抽出来的基础。
4. 为什么最后拆出来的是 stream-core
4.1 我没有先拆 runtime-core,也没有拆整个 chat runtime
这是很多人看到目录变化之后,第一反应会问的问题:
"既然已经有 runtime 了,为什么不直接抽一个 runtime-core?"
原因很简单:今天的 runtime 还不是一块可以稳定复用的内核,它仍然包含大量应用级判断:
- planning 阶段怎么走
- tool 结果什么时候可以直出
- skill / tool / mcp 怎么组合
这些东西现在抽出去,只会把编排层也一起包化。
4.2 stream-core 代表的是一块已经稳定的流式内核
真正被我拆出去的,不是一个"大 runtime",而是一块已经跑稳的流式内核。
它的稳定主要体现在几件事上:
- 协议已经比较稳定
- 生命周期已经比较稳定
- writer 的职责已经比较稳定
- 和具体业务编排之间是弱耦合关系
StreamLifecycle 就是一个很典型的例子:
ts
export class StreamLifecycle {
private started = false
private terminated = false
emitStartOnce() {
if (this.started || this.terminated || this.isClosed()) {
return false
}
this.started = true
this.writeChunk({
type: 'start',
messageId: createId(),
})
return true
}
}
它不关心 skill、tool、MCP 这些上层语义,只关心流式生命周期本身是否被正确表达。这种代码,就很适合先沉淀下来。
4.3 stream-core 的职责边界是什么
这个包的边界其实非常克制,当前只放这些内容:
protocollifecycleerror chunkstatic parts writerweb NDJSON writer
对应源码大致位于:
packages/stream-core/src/protocol/(定义start / text / reasoning / tool / resource / error / finish这些 chunk 类型)packages/stream-core/src/core/stream-lifecycle.ts(统一处理流开始、结束和 runtime error 的终态收口)packages/stream-core/src/core/stream-error.ts(统一创建和写出错误 chunk)packages/stream-core/src/core/static-parts.ts(把静态文本或推理内容写成标准流式 part)packages/stream-core/src/adapters/web/chunk-writer.ts(把 chunk 逐行编码成 NDJSON 并写进 WebReadableStream)
而这些内容我明确没有放进去:
- orchestrator(聊天主链的阶段编排和策略判断)
- session(按请求拼出模型上下文、messages 和 active tools)
- tool runtime(工具校验、执行与展示映射)
- skill / MCP 编排(当前应用里的能力路由和外部能力接入层)
因为它们今天仍然属于"应用内编排层",还不是适合沉淀成公共内核的部分。
4.4 这一版拆包的核心取舍
如果把这一版的取舍压成一句话,我会这样说:
我不是为了让项目"看起来更像架构"而拆包,而是只把已经在应用内跑稳、边界也相对清楚的那部分流式内核,正式沉淀了下来。
这也是为什么它最终叫 stream-core,而不是一个一看就想把所有东西都装进去的名字。
5. 在 pnpm monorepo 里,把它真正落成内部 workspace 包
5.1 packages/stream-core 的目录与包名设计
这个包的目录和命名,我一开始就尽量做得很直白:
- 目录:
packages/stream-core - 包名:
@ai-mind/stream-core
这个命名本身就在表达边界:它承接的是 stream core,不是整个 chat runtime。
5.2 为什么我给它做了清晰的 exports,而不是只有一个根入口
内部包也需要边界,不能先暴露一个大入口,后面再慢慢补救。
这次我给 stream-core 做了明确的 exports:
- 根入口(暴露 stream-core 的核心能力)
./protocol(只暴露流式协议类型)./web(只暴露面向ReadableStream的 NDJSON writer 适配器)
对应配置在 packages/stream-core/package.json:
json
"exports": {
".": {
"types": "./build/types/index.d.ts",
"require": "./build/cjs/index.js",
"import": "./build/esm/index.mjs"
},
"./protocol": {
"types": "./build/types/protocol/index.d.ts",
"require": "./build/cjs/protocol/index.js",
"import": "./build/esm/protocol/index.mjs"
},
"./web": {
"types": "./build/types/adapters/web/index.d.ts",
"require": "./build/cjs/adapters/web/index.js",
"import": "./build/esm/adapters/web/index.mjs"
}
}
这样做的价值不只是"写得更正规",而是让消费边界从一开始就足够明确:
- 根入口给稳定基础能力
./protocol单独暴露协议类型./web单独暴露面向 Web 流响应的适配能力
5.3 为什么我选择双产物构建,而不是只做单一格式
我没有把它做成一份"先能跑起来再说"的源码目录,而是直接按一个内部包去收它的产物形态。
当前 stream-core 输出的是三类产物:
build/cjsbuild/esmbuild/types
我更想强调的不是"格式有几种",而是内部 workspace 包一旦开始承担复用职责,就应该被当成一个完整工程单元对待。
它不再只是 app 目录里被移动出去的一份代码,而是一层有明确导出、有独立产物、有自己工程边界的内部能力。双产物构建在这里也不是为了"看起来更像公共包",而是为了先把内部消费形态收规整。
5.4 apps/webapp 是怎么接入这个 workspace 包的
让一个内部包真正落到应用里,不能只停在"把 import 改过去"。
这次 apps/webapp 的接入主要包括三件事:
- 依赖用
workspace:* - Next.js 通过
transpilePackages消费它 - TypeScript 侧使用
moduleResolution: "bundler"
对应配置分别落在:
apps/webapp/package.json(声明@ai-mind/stream-core这个 workspace 依赖)apps/webapp/next.config.ts(通过transpilePackages让 Next.js 正常消费内部包)apps/webapp/tsconfig.json(通过moduleResolution: "bundler"对齐包导出解析方式)
这三件事放在一起,才算是"这个 workspace 包已经被当前应用稳定接入"。
5.5 拆成包以后,消费边界也要跟着收稳
目录拆开只是第一步,消费关系也必须跟着显式化。
所以这次除了目录和依赖本身,我也尽量把"它是一个独立工程单元"这件事落到日常约束里:包有自己的构建产物,有自己的导出边界,也有自己的验证责任。
这样一来,stream-core 不再只是"从 app 挪出去的一坨代码",而是真正可以被稳定消费的一层内部能力。
6. 拆包以后,如何保持现有应用主链不被破坏
6.1 外部入口为什么要保持稳定
这次拆包里,我一直守着一个原则:外部入口尽量不动。
当前对外稳定入口仍然是:
createChatService().streamChat()/api/chat
也就是说,底层内核在沉淀,但业务调用层的感知应该尽量保持稳定。
6.2 好的拆包,不应该让业务调用层感受到"地震"
我很认同一句话:真正好的拆包,是内部收口,外部少感知。
这次变化主要发生在内部:
chat-service回到了 facade 角色- runtime 的职责更清楚了
- stream core 被正式沉淀到了 workspace 包
而边界以上的消费方式尽量保持不变,这样拆包才是在降低演进成本,而不是把改动面放大。
6.3 这次拆包对前端消费语义有什么影响
对前端来说,这次最关键的不是"代码搬家了",而是消费语义没有被破坏。
前端仍然消费同一套流式内容:
reasoningtoolresourcetext- 统一
errorchunk
变化发生在底层:这些协议和 writer 能力,现在由 @ai-mind/stream-core 来承接。
也正因为如此,这次拆包带来的不是"前端协议换了一套",而是"协议终于有了更明确的归属层"。
7. 为什么真正的拆包,不会只停在目录和 import 上
7.1 测试目录为什么要统一到 tests/**
测试目录统一看起来像小事,但它本质上也是边界收口的一部分。
当前 webapp 侧统一到:
apps/webapp/tests/**(webapp 主链和前端消费相关的自动化测试)
package 侧独立到:
packages/stream-core/tests/**(stream-core 作为内部包的独立单测)
这样做的价值很直接:
- app 侧测试边界清楚
- package 侧测试边界清楚
- 扫描规则清楚
同时,我也补了位置校验脚本,避免测试文件再慢慢散回业务目录。
7.2 一个内部 workspace 包,也应该有自己的 test / typecheck / build
这是我这次很在意的一点,因为这直接决定它是不是一个真正成立的包。
如果一个内部包没有自己的 test / typecheck / build,那它往往还只是"被搬出去的代码",还称不上真正的工程单元。
而 packages/stream-core 现在已经有自己独立的:
buildtypechecktest
这会让后面继续演进它的成本低很多。
7.3 为什么文档资产也要一起更新
代码边界变了,文档边界也要跟着一起变。
所以跟着一起更新的内容包括:
- plan(记录这版的目标、非目标和关键取舍)
- tasklist(记录这版具体落地了哪些工作)
- runtime note(解释聊天主链现在的运行时边界)
- release(总结版本最终结果)
- architecture note(沉淀跨版本仍然有效的结构判断)
- blog material(把实现取舍整理成对外可讲的内容)
- README(同步仓库当前状态和结构)
这样以后再回头看这版,不会只看到代码改动,还能看到当时的判断、边界和取舍是怎么形成的。
8. 我从这次拆包里得到的 4 个结论
8.1 先在应用内收口边界,再拆包
应用内边界都还没稳的时候,包化通常不会让结构更清楚。
8.2 先抽稳定内核,不急着抽业务编排层
最值得先抽出去的,往往不是最大块,而是最稳定、最独立、最少业务语义的那一块。
8.3 拆包不是为了"更像架构",而是为了更低成本地演进
如果拆完以后每次修改都更困难,那这个包就没有真正帮我们降低复杂度。
8.4 pnpm monorepo 最适合承载"先验证、再沉淀"的内部架构演进
对我来说,pnpm monorepo 最大的价值不是目录看起来更专业,而是它非常适合承接一种克制的演进方式:
先在 app 内验证边界,再把已经跑稳的那部分自然沉淀成内部 workspace 包。
9. 结尾:我为什么觉得这次拆 stream-core 是值得的
9.1 它让我更清楚地看见了 Runtime 的边界
这次最直接的收获,不是仓库里多了一个包,而是 Runtime 的边界终于能被更清楚地说出来。
做完这次拆分之后,我能更明确地区分:
- facade 在哪
- runtime 编排层在哪
- 稳定流式内核在哪
这比"多了一个 package"本身更重要。
9.2 它不是平台化,而是一次克制的沉淀
我很看重这次的一点,是它足够克制。
这次我没有把整个 chat runtime 一口气打成一个"大而全"的基础包。
我只是把已经在应用里跑稳、边界也相对清楚的那部分流式内核,正式沉淀了下来。
我很看重这种节奏。它不是过度设计,而是一种更克制、也更容易继续演进的沉淀方式。
9.3 后面哪些东西,我反而不会急着拆
也正因为这次我更看重"克制",所以有些东西我反而不会急着拆。
至少在当前阶段,下面这些内容我不会急着拆出去:
chat-orchestrator(聊天主链的阶段编排和策略判断)chat-session(按请求组装模型上下文、messages 和 active tools)tool-runtime(工具校验、执行与展示映射)- 业务策略层(和当前产品问答体验强绑定的策略判断)
因为它们今天依然带有明显的应用内语义。
如果现在就急着把这些内容一起包化,只会把还在变化中的编排层也一并固化,反而失去边界。
如果用一句话收住这篇文章,我会这么写:
对我来说,这次拆包的意义,不是"多了一个 package",而是第一次把"应用内已经跑稳的稳定内核"正式沉淀了下来。
项目地址
GitHub: github.com/HWYD/ai-min...
如果这篇文章刚好对正在处理类似 Runtime / monorepo 拆分问题的同路人有一点参考价值,欢迎来仓库里看看。
如果你也对这种按版本持续演进的 AI Runtime Skeleton 感兴趣,顺手点个 Star,也能让我知道这条路线确实对外部读者有帮助。
后面我也会继续沿着 Runtime、MCP、Agent 这些方向,把这套骨架一点点往前推。