普通视图

发现新文章,点击刷新页面。
今天 — 2026年4月17日首页

React 19 源码怎么读:目录结构、包关系、调试方式与主线问题

作者 倾颜
2026年4月17日 18:21

这是我持续更新的一组 React 源码解读文章,也会尽量控制单篇篇幅,按主线一点点往里拆。
这一篇先不急着扎进某个细节,而是从整体地图开始,先把 React 运行时主线和后面阅读源码时最重要的入口理顺。

前言

第一次看 React 源码时,我们最容易卡住的地方,往往不是某个函数太难,而是看着看着就失去了方向。

一开始,我们心里通常都有几个很具体的问题:想搞懂 Fiber,想知道 setState 之后到底发生了什么,也想弄明白 useEffect 为什么总像是“晚一步”执行。可真翻进仓库之后,这些问题又很快会被新的困惑打断:

  • 这个目录到底是做什么的?
  • 这段代码在整条链路里负责哪一段?
  • 我们现在看到的,是 React 的核心逻辑,还是某个边缘实现?
  • 为什么每个点都好像懂了一点,但就是连不成一条完整主线?

所以这篇文章不急着深挖某个具体实现,而是先做一件更基础、也更重要的事:

先把 React 源码的阅读地图搭起来。

这篇文章主要想回答四个问题:

  • React 仓库里哪些地方值得先看
  • reactreact-domreact-reconcilerscheduler 大概是怎么分工的
  • 一次 React 更新的大主线到底是怎么流动的
  • 刚开始读源码时,应该按什么方式推进,才不容易迷路

这里也先说明一下版本口径:这篇文章标题写的是 React 19,因为整体讨论的是 React 19 的主线机制;但在具体源码观察上,我会先以 React 19.1.1 作为基线来展开。


一、为什么很多人看 React 源码会越看越乱

React 源码难,不只是因为代码量大。

更准确地说,它难在:层次很多,入口很多,主线很长,而且每一层都不是孤立存在的。

我们表面上想搞懂的是一个问题,比如“setState 之后发生了什么”,但它背后往往会牵出一整串东西:

  • 组件更新是怎么产生的
  • update 是怎么入队的
  • Fiber 节点怎么记录这次更新
  • React 怎么决定这次更新什么时候执行
  • render 阶段到底在算什么
  • commit 阶段又是什么时候真正改 DOM 的

也就是说,React 源码不是那种“看一个函数就能闭环”的代码。它更像一套分层协作的更新系统

如果一开始没有地图感,很容易进入一种状态:每看一段代码,都能理解一点;但每理解一点,又像是零散碎片。最后脑子里只剩下一堆词:

  • Fiber
  • Scheduler
  • render
  • commit
  • lanes
  • hooks

这些词我们都见过,但它们之间到底是什么关系,反而不清楚。

所以我更倾向于把 React 源码学习的第一步放在一个更基础的问题上:

React 到底是一套什么系统?

把这个问题先看清楚,后面再去拆 Fiber、调度、Hooks、render、commit,才不容易一路走一路散。


二、先建立一张总图:React 到底是一套什么系统

如果先把 React 粗略抽象一下,我更愿意把它理解成这样一条主线:

flowchart LR
    A[JSX] --> B[ReactElement]
    B --> C[Root / Fiber]
    C --> D[调度]
    D --> E[render]
    E --> F[commit]
    F --> G[DOM / effects]

React 运行时总主线

这张图不细,但非常重要。因为它至少先帮我们看清了三件事。

1. JSX 不是 React 运行时真正处理的最终形态

我们平时写的是 JSX,但 React 运行时真正接收到的,并不是 <App /> 这段看起来像模板的代码本身,而是编译之后的一种对象描述。

所以读源码时,第一层问题不应该是“React 怎么处理 <App />”,而应该是:

<App /> 编译之后到底是什么对象?

只要这一步没有先想清楚,后面再看 Root、Fiber、更新流程,就会总觉得前面少了一层。

2. root.render(...) 不是“立刻渲染 DOM”

很多人第一次接触 React 时,会下意识把 root.render(<App />) 理解成“把组件直接渲染到页面上”。

但从源码视角看,更准确的理解应该是:

它把一份描述 UI 的对象,送进 React 自己的更新系统。

也就是说,这一步更像“发起一次更新”,而不是“立即完成渲染”。

3. React 的更新过程,本质上分成“计算”和“提交”两段

后面我们会经常看到两个词:rendercommit

可以先记住一句很关键的话:

  • render 阶段:主要是在算,算这次更新之后“应该变成什么样”
  • commit 阶段:主要是在交,真正把结果提交到宿主环境,比如浏览器 DOM

所以 React 不是“收到更新,立刻改 DOM”的直线模型。它更像这样:

描述 UI → 进入更新系统 → 被调度 → 计算结果 → 提交结果

一旦先把这张总图建立起来,后面再去看 Fiber、Hooks、调度,就不会觉得这些东西是互相割裂的黑话。


三、先别急着翻细节:React 仓库里哪些地方值得先看

第一次打开 React 仓库时,很容易被目录吓到。但从“运行时源码阅读”的角度看,我们不需要一开始就把所有目录都研究一遍。

对这条“React 运行时主线”来说,真正值得优先关注的,主要有这几个方向。

1. packages:核心代码主战场

如果我们的目标是理解这些问题:

  • JSX 产物是什么
  • createRoot 做了什么
  • Fiber 是什么
  • 更新是怎么调度的
  • render / commit 分别在做什么
  • Hooks 为什么能工作

那么后面大部分时间,基本都会待在 packages 里。

因为真正和 React 运行时主线相关的核心逻辑,主要都在这里。

所以第一次看仓库时,不要想着“从根目录往下把所有东西都扫一遍”。更有效的做法,是先建立一个习惯:

以后提到 React 源码主线,默认先去 packages 里找。

2. fixtures:最适合做最小实验场

学习源码很怕一上来就拿业务项目调试。业务代码一复杂,React 本身的调用链很容易被应用层噪音淹没。

这时候 fixtures 的价值就出来了。它更像一个实验场:当我们只想验证某一条很小的更新链路时,最小场景会比业务项目更适合观察。

3. scripts:工程支撑层

scripts 当然重要,但不是我们建立 React 主线认知的第一入口。

对第一阶段来说,知道它主要服务于构建、测试、打包、发布等工程流程,就够了。因为现在我们的目标不是“参与 React 仓库开发”,而是“先把 React 是怎么运行起来的搞清楚”。

4. 其他方向:先知道存在,不急着深挖

比如编译器、测试、工具链等方向,当然都重要。但如果我们的目标是先建立 React 运行时的整体认识,那么优先把这条主线打通,收益会更直接。

现阶段更好的策略是:

先把运行时主线搞清楚,再考虑编译器、RSC、性能优化等专题。


四、核心包关系:reactreact-domreact-reconcilerscheduler 各自负责什么

看 React 源码时,如果只记目录,不记职责,很快还是会乱。真正有用的是把几个核心包的分工先记住。

我目前更倾向于用下面这种方式去理解它们:

flowchart TB
    A[react<br/>定义 UI 描述和上层 API]
    B[react-dom<br/>浏览器宿主环境接入]
    C[react-reconciler<br/>协调与更新主链核心]
    D[scheduler<br/>调度能力支撑层]

核心包职责

下面逐个说。

1. react:定义“怎么描述 UI”

react 这一层,更像是 React 暴露给开发者的“上层接口”和“描述模型”。

我们平时写的这些东西:

  • JSX
  • 函数组件
  • Hook
  • createContext
  • memo

最后都会落到 React 定义的一套模型里。

所以从源码学习角度看,react 回答的问题更像是:

开发者是如何把 UI 和状态意图,交给 React 的?

如果继续顺着这条线往里看,很自然就会进入 JSX 编译产物和 ReactElement 这一层。

2. react-dom:浏览器环境的接入层

对前端开发者来说,最熟悉的入口通常是:

import { createRoot } from 'react-dom/client'

const root = createRoot(container)
root.render(<App />)

这说明 react-dom 这一层解决的核心问题是:

React 怎么接到浏览器这个宿主环境上?

也就是说,它更关心“把 React 应用挂到哪、怎么挂、最终怎么和 DOM 环境打交道”。

所以我们可以先把它理解成:

浏览器场景下的宿主接入层。

3. react-reconciler:真正的源码腹地

如果说:

  • react 更偏“描述层”
  • react-dom 更偏“宿主接入层”

那么 react-reconciler 才是后面真正要深挖的核心腹地。

因为我们最关心的这些东西,几乎都和它强相关:

  • Fiber
  • work loop
  • beginWork
  • completeWork
  • render 阶段
  • commit 阶段
  • 更新如何传播
  • 副作用如何收集和提交

可以先记一句非常实用的话:

React 真正“怎么处理一次更新”,大头都在 react-reconciler 这层。

如果继续往更新主链内部走,很多关键问题最终都会落到这一层。

4. scheduler:不是主角,但非常关键

这里不必一开始就把 scheduler 的细节掰得很深,但它在整套系统里的位置,我们最好先有一个整体认识。

React 之所以不再只是“同步调用 → 直接算完 → 直接提交”,背后离不开调度能力。这部分我们可以暂时理解成:

  • 什么时候做
  • 哪个先做
  • 哪个可以稍后做
  • 当前要不要让出执行机会

这些能力,不是随便塞在某个业务函数里就能完成的,所以 React 需要一层相对独立的调度支撑。

现阶段先记住一句就够了:

scheduler 提供的是调度能力支撑,不等于 React 全部逻辑本身,但它对 React 的更新模型非常关键。


五、一次 React 更新的大主线:从 JSX 到 DOM 提交

前面把目录和核心包大致摆清楚之后,接下来最重要的一步,就是把 React 的“主线流程”先跑通。

因为无论是看 createRoot、看 Fiber、看 Hooks,还是看 beginWorkcommit,本质上都还是在拆这一条主线。

我先把它再压缩成一张图:

JSX
  ↓ 编译
ReactElement
  ↓ root.render / 触发更新
Root / Fiber Root / HostRoot Fiber
  ↓ 调度
render 阶段
  ↓ 生成本次提交所需的信息
commit 阶段
  ↓
DOM 更新 / layout effect / passive effect

这一条线里,最容易搞混的是两件事:

第一,React 运行时真正处理的不是 JSX 本身,而是 JSX 编译后的 ReactElement

第二,React 并不是一收到更新就直接改 DOM,而是先经过调度、render 计算,再进入 commit 提交

所以从源码阅读角度看,后面我们遇到的大部分概念,都能挂到这条链上。

1. JSX 先变成 ReactElement

我们平时写的是:

<App count={1} />

但 React 真正接收到的,不是这段“长得像 HTML 的语法”,而是编译产物。

所以阅读源码的第一层问题,不应该是“React 怎么处理 <App />”,而应该是:

<App /> 编译之后到底是什么对象?

2. root.render(element) 把更新送进系统

对很多开发者来说,root.render(<App />) 最容易产生一个错觉:好像这行代码一执行,页面就立刻被渲染出来了。

但源码视角下,更准确的理解应该是:

root.render 负责把一份 element 更新送进 React 的根节点更新体系。

也就是说,这一步更像“发起一次更新”,而不是“直接完成渲染”。

3. Root / Fiber 系统接管这次更新

一旦更新进入系统,它就不再只是一个普通对象了。React 会把它放进 Root/Fiber 这套结构里,让后续调度、计算、提交都有地方可挂。

所以后面当我们看到这些词时,不要把它们看成独立概念:

  • Root
  • FiberRoot
  • HostRoot Fiber
  • update queue

它们其实都属于 React 这套更新系统的基础设施。

4. 调度决定“现在做不做、先做哪部分”

React 不是简单地“收到更新 → 马上全做完”。它还要决定:

  • 这次更新优先级高不高
  • 要不要马上做
  • 能不能让一部分工作稍后做
  • 当前阶段能不能让出执行机会

这时候调度层就进来了。

所以后面我们看到 lanes、调度入口、任务安排的时候,本质上是在看 React 如何安排“这次更新该怎么被执行”。

5. render 阶段负责计算结果,不直接提交

render 阶段是很多人第一次读源码时最容易误解的部分。因为“render”这个词太像“渲染到页面”。

但从源码视角看,render 阶段更准确的理解应该是:

它在算下一次要提交什么,而不是立即把结果改到页面上。

这一阶段里,React 会基于当前树和本次更新,逐步构造工作中的新树,并收集这次提交所需的信息。

所以后面我们看到:

  • beginWork
  • completeWork
  • work loop
  • flags / subtreeFlags

本质上都是 render 阶段里的核心组成。

6. commit 阶段才真正提交结果

当 render 阶段把“这次更新要做什么”算得差不多了,React 才会进入 commit 阶段。

到了这一阶段,才会真正发生这些事:

  • 插入、更新、删除 DOM
  • 执行 layout 相关副作用
  • 在后续时机执行 passive effect

所以 React 整体并不是一段线性同步逻辑,而更像一条清晰的更新流水线:

描述 UI → 发起更新 → 调度 → render 计算 → commit 提交


六、React 源码应该怎么读:按问题读,不按文件读

知道主线之后,接下来的问题就变成:

那源码到底该怎么读?

我自己的建议是:按问题读,不要按文件读。

也就是说,不要一上来就给自己定任务:“今天我要看完某个文件。”更好的方式,是先定一个问题,再去找这个问题对应的入口和调用链。

1. 先问问题,再找入口

比如我们可以先问自己这些问题:

  • JSX 编译后到底是什么
  • createRoot(container) 到底创建了什么
  • root.render(<App />) 做了什么
  • setState 之后发生了什么
  • DOM 是在 render 阶段更新,还是在 commit 阶段更新
  • Hooks 为什么必须按顺序调用

这样做的好处是,源码不再是一整片森林,而是变成了几条有明确方向的小路。

2. 每次只追一条最小闭环

很多人读源码会越看越累,还有一个原因:一开始就拿复杂场景下手。

更好的方法,是先拿一个最小例子:

const root = createRoot(container)
root.render(<App />)

或者:

function App() {
  const [count, setCount] = useState(0)
  return <button onClick={() => setCount(c => c + 1)}>{count}</button>
}

我们只追一条最短链路:

  • 这个 element 怎么进入系统
  • 这次更新怎么入队
  • 什么时候开始 render
  • 什么时候 commit
  • effect 什么时候执行

只要最小闭环走通一次,后面再看复杂场景,心里就会稳很多。

3. 先看入口函数,再看核心数据结构

源码阅读里有一个很实用的原则:

入口函数负责告诉我们“从哪里开始追”,数据结构负责告诉我们“数据是怎么流动的”。

比如:

  • 当问题落在 JSX 编译产物时,重点通常是 ReactElement 这个对象本身
  • 当问题落在应用启动时,重点通常是 Root / HostRoot Fiber 这层结构
  • 当问题落在更新如何进入系统时,重点通常是 Update、UpdateQueue、Lane
  • 当问题落在 render 过程时,重点通常是 Fiber、flags、workInProgress
  • 当问题落在 Hooks 内部机制时,重点通常是 Hook 链表以及它和 Fiber 的关系

4. 阅读源码时,最好始终问一句:它在主线里负责什么

无论我们现在看到的是:

  • 一个目录
  • 一个包
  • 一个函数
  • 一个字段
  • 一个变量名

都先问一句:

它在整条更新主线里,负责哪一段?

只要这个问题一直留在脑子里,源码阅读就不容易发散。


七、调试方式怎么选:从只读到可断点

这部分我不打算写成环境搭建教程,因为对刚开始阅读源码的人来说,更重要的还是先建立地图,再逐步进入调试。我更倾向于把调试方式分成三个层次。

1. 第一层:先只读,不着急跑全链路

刚开始时,不一定要马上把 React 仓库完整跑起来,也不一定要急着深挖每个入口。

这个阶段更重要的是:

  • 建立总图
  • 记住核心包职责
  • 知道接下来继续往里看时,核心问题会落在哪些位置
  • 对“从 JSX 到 commit”的主线有整体印象

2. 第二层:用最小 demo 打断点追入口

当我们开始进入具体主题,比如:

  • JSX 到 ReactElement
  • createRoot
  • root.render
  • setState
  • useEffect

这时候最好的方式,就是准备一个最小 demo,然后围绕一个非常具体的问题去断点。

不要想着“今天调试 React”,而要想着:

  • 今天只看 createRoot 做了什么
  • 今天只看一次 setState 怎么入队
  • 今天只看 useEffect 在什么时候被记录、什么时候被执行

问题越单一,断点越清晰,收获越大。

3. 第三层:围绕一条具体链路深挖到底

真正进入深入阶段时,我们的目标也不该是“把 React 全部调完”。更现实也更有效的目标是:

  • 把一次更新从触发到提交完整走通
  • 把一个 Hook 从调用到记录到执行完整走通
  • 把 Root、HostRoot Fiber、update queue 的关系彻底理顺

换句话说,调试不是为了证明“我能跑源码”,而是为了回答一个具体问题。


八、顺着这张地图继续往里看,我们会遇到哪些核心问题

到这里,这篇“阅读地图”其实就差不多搭完了。

如果继续顺着同一条主线往里看,接下来最核心的问题,大致会落在这些位置:

1. JSX 到 ReactElement

JSX 编译后到底是什么?React 运行时最先拿到的对象长什么样?

2. createRootroot.render

React 应用启动时,到底创建了什么?Root 和 HostRoot Fiber 是什么关系?

3. Fiber 到底是什么

Fiber 为什么不是 ReactElement,也不是 DOM?React 为什么需要 Fiber?

4. 从 setState 到调度

一次更新是怎么进入系统的?Update、UpdateQueue、Lane 分别扮演什么角色?

5. render 阶段

beginWorkcompleteWork 在做什么?render 阶段为什么不直接改 DOM?

6. commit 阶段

DOM 到底什么时候更新?layout effect 和 passive effect 分别在什么时机执行?

7. Hooks 内部原理

Hooks 为什么必须按顺序调用?useStateuseEffect 是如何挂到 Fiber 上的?

把这些问题串起来之后,React 源码在我们脑子里就不再是一堆零散名词,而会慢慢变成一条完整的更新链路。


结语

React 源码最难的地方,从来都不是某一个函数本身。

真正难的是:如果没有地图,很多细节都会看起来彼此割裂。今天看到 Fiber,明天看到 Hook,后天又看到 commit,名词越来越多,但主线反而越来越模糊。

所以在真正扎进细节之前,先把 React 当成一套系统看清楚,会让后面的阅读顺很多。

当我们先知道:

  • React 整体是一条怎样的更新主线
  • 仓库里哪些地方和这条主线直接相关
  • 四个核心包分别负责什么
  • 继续往里读时,核心问题大概会落在哪些位置

那接下来再看 ReactElement、Root、Fiber、调度、render、commit、Hooks,很多原本抽象的词,才会慢慢落地。

如果这篇“阅读地图”已经搭起来了,那么下一步最自然的切口,就是回到主线最前面,先看一个问题:

React 真正接收到的第一个核心对象,到底长什么样?

如果这篇对你有帮助,欢迎点个赞支持。后面我也会继续把这组 React 源码文章慢慢补完整。

这组源码解读文章也会同步整理到 GitHub 仓库里,方便集中查看和持续更新:

GitHub: github.com/HWYD/source…

如果觉得这组内容对你有帮助,也欢迎顺手点个 Star。

最近在做的一个 AI 项目

最近我也在持续迭代一个 AI 项目:AI Mind
如果你对 AI 应用工程化、Tool Calling、Skill Runtime、MCP 这些方向感兴趣,欢迎来看看。

GitHub: github.com/HWYD/ai-min…

如果觉得项目还不错,也欢迎顺手点个 Star。

pnpm monorepo 下,如何把 Next.js 应用里的稳定内核拆成内部 workspace 包

作者 倾颜
2026年4月16日 16:18

在一个 Next.js 应用里,当某些模块越来越稳定、越来越可能被复用时,什么时候应该把它们拆成 packages/* 里的内部 workspace 包?

我在 AI Mind v0.0.10 里处理的,就是这样一个问题。

先简单介绍一下这个项目。AI Mind 不是一个一次性做完的 AI 产品,而是一个按版本持续演进的 AI Native Runtime Skeleton。它从本地聊天闭环出发,逐步长出结构化流式协议、Tool Calling、Skill Runtime、MCP 接入,以及后面的 Agent / 数据层能力。

ai-1.gif

当前主应用在 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/webapppackages/stream-core 的结构示意图

v0.0.10-stream-core-cover-01.png


1. 为什么这次拆包不是从 package 开始,而是从 Runtime 收口开始

1.1 拆包不是目标,稳定边界才是目标

真正值得优先解决的,不是“怎么拆包”,而是“边界是不是已经稳了”。

在工程里,拆包本身并不天然代表结构更好。目录拆得更细,也不等于边界就更清楚。真正关键的是,我们能不能先回答下面这几个问题:

  • 这一层的语义是不是已经稳定了?
  • 它是不是已经不再强依赖当前应用里的业务编排?
  • 如果现在把它抽出去,边界会更清楚,还是只会多一层跳转?

如果这些问题还没想明白,拆包通常不会减少复杂度,只会把复杂度换个目录继续保存。

边界没稳时,抽出去的往往不是“可复用内核”,而是一份还在变化中的局部实现。它带来的结果通常也不难想象:

  • app 内还得持续频繁修改它
  • 对外接口会跟着反复抖动
  • 主链职责没有更清楚,反而多了一层跨目录理解成本

所以对我来说,拆包的前提不是“能不能拆”,而是“是不是已经稳到值得拆”。

1.2 这个问题是怎么在我的项目里出现的

AI Mind 当前是一个 Next.js + pnpm monorepo 的 AI Webapp,主应用在 apps/webapp

到这一轮之前,仓库层面的 monorepo 形态其实已经在了,但聊天主链里不少核心逻辑仍然集中在 app 内部。换句话说,目录先搭起来了,Runtime 的边界却还没有完全长清楚。

所以我要解决的,本质上不是“怎么把 monorepo 配起来”,而是“在已经存在的 monorepo 里,哪些东西真的成熟到值得沉淀成内部 workspace 包”。

如果只看目录变化,这一版像是做了两件事:

  1. chat-service 拆薄
  2. 新建了 packages/stream-core

但从工程演进角度看,它们其实是一件事的前后两步:

先把 apps/webapp 里的聊天主链收口成“薄 facade + runtime 编排层”,再把其中已经稳定的流式内核沉淀成内部 workspace 包。

也正因为先做了前一步,后面“到底什么值得拆”这件事才开始变得清楚。

我最后把这版真正要回答的问题,收成了两个判断:

  1. 聊天主链内部边界是否已经足够清晰?
  2. 哪一部分能力已经稳定到值得从 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 编排层”

我最后把主链收成了一个更容易解释、也更容易继续演进的结构:

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 现在的角色已经很克制了,它不再承接整条链路的所有细节,而是只负责稳定入口和响应包装:

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 这些生命周期终态只发一次)
  • error chunk helper(统一生成和写出 error chunk)
  • 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 就是一个很典型的例子:

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 的职责边界是什么

这个包的边界其实非常克制,当前只放这些内容:

  • protocol
  • lifecycle
  • error chunk
  • static parts writer
  • web 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 并写进 Web ReadableStream

而这些内容我明确没有放进去:

  • 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

"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/cjs
  • build/esm
  • build/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 这次拆包对前端消费语义有什么影响

对前端来说,这次最关键的不是“代码搬家了”,而是消费语义没有被破坏。

前端仍然消费同一套流式内容:

  • reasoning
  • tool
  • resource
  • text
  • 统一 error chunk

变化发生在底层:这些协议和 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 现在已经有自己独立的:

  • build
  • typecheck
  • test

这会让后面继续演进它的成本低很多。

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 这些方向,把这套骨架一点点往前推。

昨天以前首页

接入 MCP,不一定要先平台化:一次 AI Runtime 的实战取舍

作者 倾颜
2026年4月10日 09:21

本文对应项目版本:v0.0.9

这半年只要聊到 MCP,讨论几乎都会很快滑向同一个方向:

  • 怎么做平台
  • 怎么做编排
  • 怎么管多个 server
  • 怎么继续接 Agent

这条路线当然成立,但它有个很容易被跳过的前置问题:

你现在的系统,真的已经准备好接住 MCP 了吗?

如果答案还是模糊的,那“先平台化”很多时候只是把问题往后推。

你会先做出一套看起来很完整的抽象,然后再回头发现,真正难的不是平台长什么样,而是更基础的三件事:

  • 现有 Runtime 能不能稳定消费 MCP Tool
  • 文件读取这种能力到底该算 Tool 还是 Resource
  • 前端能不能把这两类能力真实区分开

我这次做的,就是先不跳到“大而全”的那一步。

我没有单独起一套 MCP Runtime,也没有直接继续做 Agent,而是先做了一件更小、但我觉得更值的事:

把 MCP 当成“能力来源层”,接进现有 Skill Runtime,先证明它能在真实主链里工作。

这篇文章不会把重点放在“我接了几个 server”上,而会重点讲清楚 3 个更实际的问题:

  1. 为什么我没有先做平台化
  2. 为什么天气先走 MCP Tool,而文件读取必须升级成 MCP Resource
  3. 为什么前端从这一步开始必须正式区分 Tool card 和 Resource card

如果你也是下面这些情况,这篇会比较对路:

  • 你已经有 Tool Calling / Skill Runtime,正在想 MCP 该怎么进来
  • 你刚开始接触 MCP,想先理解它在工程里到底怎么落地
  • 你不想先看一堆概念图,而是想看一个真实项目是怎么接进来的

mcp-1.gif

说明:天气查询仍然展示为 Tool card,读取 README.md 已经展示为 Resource card,前端能直接看出两类能力的区别。

先说结论:MCP 最先改变的,通常不是架构层数,而是能力来源

如果只用一句话概括这次实践,我会写成:

接入 MCP,最先该改变的,不一定是 Runtime 形态,而往往是“能力从哪里来”。

比如在我这个项目里,本来就已经有两类能力:

  • city-weather
  • local-text-read

从模型视角看,它们只是两个可调用能力。

但从工程视角看,这两个能力其实属于完全不同的两类来源:

  • 天气更像“调用一个外部动作”
  • 文件读取更像“读取一个受控资源”

一旦开始用 MCP 接这两类能力,你很快就会遇到两个非常真实的问题:

  1. 模型看到的能力名,要不要跟着 MCP 一起改?
  2. 文件读取这种能力,到底还该不该继续伪装成 Tool?

这两个问题,远比“要不要先做平台化”更应该优先回答。

如果你第一次接触 MCP,只要先搞清 4 个词就够了

网上关于 MCP 的资料很多,但如果你现在是第一次真正在项目里接它,我建议先不要把自己扔进一整套协议细节里。

先搞懂下面 4 个词,已经足够把这篇读明白。

Host

Host 可以简单理解成:

你自己的应用里,负责连接和消费 MCP server 的那一层。

它不一定是一个巨大的平台,也不一定是一个可视化控制台。

在这个项目里,Host 做的事情很朴素:

  • 知道有哪些 MCP server
  • 什么时候拉起它们
  • 什么时候调用 Tool
  • 什么时候读取 Resource

Tool

Tool 是动作型能力。

你给它参数,它帮你执行一次动作,然后返回结果。

比如天气查询就很适合 Tool 语义:

  • 输入:城市名
  • 输出:当前天气文本

Resource

Resource 是读取型能力。

它不像 Tool 那样强调“执行一次动作”,更像是:

在一个受控边界里,读取一段已经存在的内容。

比如:

  • 读取 README.md
  • 读取 package.json
  • 读取某个受控 URI 对应的内容

stdio

stdio 可以理解成最小闭环方案。

也就是你的应用直接通过本地进程和 MCP server 通信,而不是一上来就做远程 HTTP、鉴权、编排这些更重的东西。

这次我故意先只做本地 stdio,因为它最适合先验证一件事:

MCP 这套能力来源,能不能被现有主链稳定吃进去。

而且当前这个 Host 也不是额外再造的一层平台,而是基于官方 SDK 接起来的最小消费层。

这版的 MCP SDK 接入方式

很多文章讲 MCP,会把重点放在协议概念上。

但真到接入时,更实际的问题其实是:

你准备用什么方式把 server、client 和 transport 这几层真正落下来?

我这版没有把 SDK 当成一个“顺手一提的依赖”,而是把它放在了 Host 基础层最合适的位置上。

具体来说,当前这条链路是很清楚的:

  • server 侧用官方 SDK 的 McpServerStdioServerTransport 暴露能力
  • client 侧用官方 SDK 的 ClientStdioClientTransport 发起连接
  • 中间再用一个 MCPClientManagerserverId 复用 client

也就是说,SDK 在这里不是直接跑到业务层里到处被调用,而是被收在一条比较干净的基础层里:

  • server 负责声明 MCP 能力
  • client 负责连接、初始化、调用和错误收束
  • manager 负责复用 client
  • adapter 再往上把 MCP 结果翻译成当前 Runtime 认识的 Tool / Resource 结构

这段代码解决的问题是:把这版 MCP 接入里最关键的 server / client / transport 落位方式讲清楚。

import { Client } from '@modelcontextprotocol/sdk/client/index.js'
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'

const client = new Client(MCP_CLIENT_INFO, {
    capabilities: MCP_CLIENT_CAPABILITIES,
})

const server = new McpServer({
    name: 'weather-server',
    version: '0.0.9',
})

这段代码本身不复杂,但它背后有几个很实际的工程判断:

  • client、transport、server 都直接走官方公开子路径,后续升级时更容易对照官方文档
  • MCPClient 只处理单个 server 的连接、调用和超时,不顺手掺业务语义
  • MCPClientManager 只负责按 serverId 复用 client,避免请求一多就重复拉进程
  • 真正跟业务相关的参数映射、错误翻译和结果规整,继续留在 adapter 层

这样做的好处,不是“更标准”这么抽象,而是非常实际:

  • 你能很清楚地知道 server 写在哪一层
  • 你能很清楚地知道 client 负责什么、不负责什么
  • 你后面要接第二个、第三个 MCP server 时,不需要再把主链扒开重写

同时我也把 MCP 代码明确收在服务端 / Node runtime,不往浏览器侧扩。

对这版来说,SDK 这一块真正想解决的不是“怎么秀一套接入技巧”,而是:

先把 server、client、transport 这条基础层收稳,再往上谈平台化、编排和 Agent。

为什么我没有先单独起一套 MCP Runtime

这是这篇最核心的取舍。

很多人一看到 MCP,第一反应是对的:

“这个东西以后肯定会越来越多,那是不是应该先抽一层独立 Runtime?”

问题是,这个判断太早了。

因为如果你现在的项目里,连下面这些问题都还没证明:

  • 现有 Tool Runtime 能不能稳定承接 MCP Tool
  • 现有消息协议能不能承接 MCP Resource
  • 现有前端能不能真实表达 Tool / Resource 差异
  • 现有 Skill 边界会不会被 MCP 污染

那你先做出一层新 Runtime,本质上还是在“想象未来需求”。

而我这次更想先证明“今天真实会发生什么”。

所以我有意压住了这几个方向:

  • 不做远程 HTTP MCP
  • 不做多 server 编排
  • 不做 Resource Picker
  • 不做 server 状态面板
  • 不把 utility-skill 一起迁进去
  • 不提前进入 Agent

这不是说这些不重要,而是因为这一版更值得验证的,是一个更朴素的问题:

现有系统能不能在不推翻主链的前提下,先把 MCP Tool 和 MCP Resource 接进来。

如果答案是可以,那后面的平台化才是顺势而为。

如果答案是还不行,那先做平台化反而会把问题藏起来。

这次最重要的策略:能力名不变,只替换底层来源

这是我这次最想强调的一点。

接 MCP 的时候,我没有让模型开始学习一堆新名字。

我没有把:

  • city-weather
  • local-text-read

改成:

  • get_weather
  • project-resource-read
  • resources/read

相反,我刻意让模型继续看到原来的能力名。

也就是说,从模型视角看,一切几乎没变:

  • 它还是在调用 city-weather
  • 它还是在调用 local-text-read

但运行时已经知道,底层来源变了:

  • city-weather 实际走 weather-server.get_weather
  • local-text-read 实际走 project-files-server.resources/read

为什么这个取舍重要?

因为它能把变化压缩在最合适的一层。

这样一来:

  • 模型心智没被打乱
  • Skill 边界没被打乱
  • 真正发生变化的,是能力来源

这会让你更容易验证问题到底出在哪里。

否则你同一版里同时改:

  • 模型看到的能力名
  • Skill 分层
  • 底层能力来源
  • 前端展示方式

最后哪怕效果不好,你也很难判断到底是哪一层出了问题。

天气为什么先走 MCP Tool

如果你想先验证 MCP Tool 主链,天气是一个特别好的切入点。

原因不是它业务价值有多高,而是它特别“像 Tool”:

  • 输入参数简单
  • 调用动作明确
  • 返回结果直观
  • 成功失败都容易观察

所以我先做了一个很小的 weather-server,只暴露一个 Tool:

  • get_weather

然后在项目里继续保留模型熟悉的名字:

  • city-weather

中间用一层 adapter 做映射。

这段代码解决的问题是:

模型侧保留原有能力名,运行时把它稳定映射到底层 MCP Tool,同时把结果整理成当前主链已经认识的结构。

const WEATHER_SERVER_ID = 'weather-server'
const WEATHER_TOOL_NAME = 'get_weather'

export const weatherToolAdapter: MCPToolAdapter<WeatherToolAdapterInput> = {
    async call(input): Promise<MCPToolAdapterResult> {
        const response = await mcpClientManager.callTool(WEATHER_SERVER_ID, {
            arguments: { city: input.city },
            name: WEATHER_TOOL_NAME,
        })

        const outputText = extractToolText(response.result)

        if (response.result.isError) {
            throw new MCPHostError('REQUEST_FAILED', outputText || '天气 MCP Tool 调用失败。')
        }

        return {
            action: 'current',
            inputText: `city=${input.city}`,
            outputText,
            serverId: WEATHER_SERVER_ID,
            source: 'mcp',
            title: 'city-weather',
            toolName: 'city-weather',
        }
    },
}

这里的重点不是“代码能调用成功”,而是 adapter 做了 4 件很关键的事:

  • 参数映射
  • 错误翻译
  • 结果标准化
  • 来源信息补齐

这意味着 MCP 原始结构并没有直接漏进主运行时。

主链只知道:

  • 这是一次 city-weather
  • 来源是 MCP
  • 来自 weather-server
  • 已经有了标准化结果

这就是我说的“先改变能力来源,而不是先改 Runtime 形态”。

mcp-weather.png

说明:展示 city-weather 在用户视角下仍然是原来的能力,但卡片上已经能看到 来源:MCPweather-server

文件读取为什么不能继续伪装成 Tool

天气这条线解决的是“Tool 怎么接进来”。

文件读取解决的是另一个更重要的问题:

有些能力本质上就不该再被当成 Tool。

以前很多项目做本地文件读取时,会顺手做一个 Tool:

  • 输入文件名
  • 返回文件内容

这当然能跑,但一旦你开始用 MCP 去理解它,就会发现它的语义其实不太像 Tool,而更像 Resource。

为什么?

因为文件读取更像是在做下面这些事情:

  • 读取某个 URI 对应的内容
  • 只读,不执行副作用
  • 有明确边界
  • 适合展示预览

这其实正是 Resource 的典型场景。

所以这次我没有继续让文件读取伪装成“另一个 Tool”。

我做的是:

  • 模型侧继续保留 local-text-read
  • 底层把它转成 project://README.md 这样的 Resource URI
  • 通过 project-files-server.resources/read 去读取

而且原来的安全边界全部保留:

  • 只允许根目录直接文本文件
  • 不允许子目录
  • 不允许绝对路径
  • 不允许 ../
  • 非文本文件拒绝

这段代码解决的问题是:

把文件读取从“本地直接访问”升级成“受控 Resource 读取”,同时把预览信息整理成前端能直接消费的结构。

export const projectFileResourceAdapter: MCPResourceAdapter<ProjectFileResourceAdapterInput> = {
    async read(input): Promise<MCPResourceAdapterResult> {
        const safeFilename = assertSafeRootFilename(input.filename)
        const uri = createProjectResourceUri(safeFilename)
        const response = await mcpClientManager.readResource(PROJECT_FILES_SERVER_ID, { uri })
        const textContent = extractTextContent(response.result)

        if (!textContent) {
            throw new MCPHostError('REQUEST_FAILED', '项目文件 MCP Resource 没有返回可用文本内容。')
        }

        return {
            content: textContent.text,
            contentPreview: createProjectResourcePreview(textContent.text),
            previewChars: MAX_PROJECT_RESOURCE_PREVIEW_CHARS,
            resourceName: safeFilename,
            serverId: PROJECT_FILES_SERVER_ID,
            status: 'completed',
            uri,
        }
    },
}

这段代码带来的变化,不只是“读文件换了个通道”,而是整个系统开始正式承认:

  • 天气是 Tool
  • 文件读取是 Resource

这两类能力不该继续被混成一类。

这也是为什么我会说,文件读取这一步其实比天气更关键。

因为它逼着整个系统第一次认真区分:

什么是动作型能力,什么是读取型能力。

前端为什么必须开始区分 Tool card 和 Resource card

很多后端接入类文章,写到这里就结束了。

但我觉得 MCP 真正开始成立,恰恰是在前端。

因为如果前端还是把所有东西都塞回 Tool card,那 Resource 在产品层面其实根本没有被表达出来。

用户只会感觉:

“哦,又多了一个工具调用卡片。”

但他不会理解系统已经多了一种新的能力类型。

所以这次我没有只改后端,也同步改了流式协议。

这段代码解决的问题是:

给 Resource 一套独立的流式生命周期,而不是继续借 Tool 协议蹭展示。

export interface ResourceStartChunk {
    type: 'resource-start'
    partId: string
    resourceName: string
    uri: string
    serverId: string
}

export interface ResourceEndChunk {
    type: 'resource-end'
    partId: string
    resourceName: string
    uri: string
    serverId: string
    contentPreview?: string
    isTruncated?: boolean
    previewChars?: number
}

export interface ResourceErrorChunk {
    type: 'resource-error'
    partId: string
    resourceName: string
    uri: string
    serverId: string
    message: string
}

这三个事件看起来只是多了几个类型,但它们的意义很大:

  • Resource 有自己的开始、完成、失败
  • 它不是 Tool 的一种特殊状态
  • 它应该以另一种 part 进入消息模型

前端接着也按这个语义去消费它。

这段代码解决的问题是:

把 Resource 当成正式消息 part 处理,而不是继续硬塞进 Tool part。

case 'resource-start': {
    const messageId = activeStreamRef.current.messageId

    if (!messageId) {
        return
    }

    updateMessages(current =>
        appendPart(current, messageId, createResourcePart(chunk.partId, chunk.resourceName, chunk.uri, chunk.serverId))
    )
    return
}

case 'resource-end': {
    const messageId = activeStreamRef.current.messageId

    if (!messageId) {
        return
    }

    updateMessages(current =>
        updateResourcePart(current, messageId, chunk.partId, part => ({
            ...part,
            status: 'completed',
            contentPreview: chunk.contentPreview,
            isTruncated: chunk.isTruncated,
            previewChars: chunk.previewChars,
        }))
    )
    return
}

这一步带来的直接效果是:

  • 天气继续显示 Tool card
  • 文件读取开始显示 Resource card
  • 用户第一次能直观看到两类能力的边界

这不是简单的 UI 小修,而是协议层、消息模型层、产品表达层一起升级。

mcp-resource.png

说明:展示资源名称、URI、serverId、状态、内容预览,以及它和 Tool card 的区别。

为什么是 reader-skill 在承接 MCP,而不是别的层

如果你已经有 Tool Runtime,又想开始接 MCP,很容易冒出一个问题:

“要不要新起一个 mcp-skill?”

我这次没有这么做。

原因很简单:

这版里需要接入 MCP 的两类能力,本来就属于 reader-skill 的边界:

  • 天气查询
  • 文件读取

它们的共同点不是“都是 MCP”,而是“都是外部上下文获取”。

也就是说,真正稳定的边界不是 MCP,而是 reader-skill 本身。

这也是我为什么一直觉得,Skill / MCP / Agent 这几层不要轻易混:

  • Tool 是原子能力
  • Skill 是能力模式
  • MCP 是能力来源通道
  • Agent 才是计划与继续决策

如果一接 MCP 就先做一个 mcp-skill,很容易把“能力来源”错误地提升成“能力模式”。

但实际上,在这次实践里更自然的做法是:

  • 继续保留原有 Skill 边界
  • 只替换它底层消耗的能力来源

这样好处很明显:

  • 普通聊天主链不被污染
  • utility-skill 不被连带改造
  • reader-skill 反而变得更有解释力

因为从这一步开始,reader-skill 不再只是“一个能读文件、查天气的 Skill”,而是:

第一个正式承接 MCP 能力来源的 Skill。

这次实践真正证明了什么

如果把“项目支持 MCP 了”这种大而泛的说法放一边,这次实践真正证明的其实是下面几件更具体的事。

1. 现有 Runtime 不重做,也能接入 MCP

这很关键。

因为它说明现有主链不是必须推翻重来,MCP 可以先作为能力来源层进入现有系统,而且 SDK 的落位方式也可以先收稳。

2. 能力名不变,只换底层来源,是非常有效的过渡策略

这能最大程度保持模型心智稳定,也能更清楚地定位问题发生在哪一层。

3. 文件读取一旦升级成 Resource,整个系统的分层会明显变清楚

这一步不只是“换个 API”,而是在认真区分:

  • 什么是 Tool
  • 什么是 Resource

4. 前端是否区分 Tool / Resource,决定了这次接入是不是“真的成立”

如果前端不区分,MCP Resource 在产品层面就还是半成品。

5. 现在还没必要急着做 Agent

因为当前更值得先收稳的是:

  • MCP Tool / Resource 的真实接入边界
  • reader-skill 的承接方式
  • 协议和前端表达是不是已经站住

如果这些问题都还没收住,就急着往 Agent 走,很容易把“能力接入问题”和“任务调度问题”混在一起。

最后

如果你现在也在做 MCP 接入,我会很推荐先问自己一个问题:

我现在真正缺的,是一套平台,还是一条能被主链真实验证的接入路径?

这两个答案,最后导向的实现方式会完全不同。

对这个项目来说,这次更合适的答案是后者。

所以我没有先做平台化,而是先把 MCP 放进一个真实会被用到的地方:

  • 天气走 MCP Tool
  • 文件读取走 MCP Resource
  • reader-skill 成为第一层承载层
  • 前端正式区分 Tool card 和 Resource card

这条路听起来不激进,但它非常扎实。

因为从这一版开始,MCP 不再只是“以后可能会接”的方向,而是已经进入当前系统主链、真正开始工作的能力来源层。

项目地址

GitHub: github.com/HWYD/ai-min…

如果这篇文章或这个项目对你有帮助,欢迎到仓库里看看,也欢迎顺手点个 Star。
后面我会继续沿着 Skill -> MCP -> Agent 这条线,把这个 Runtime Skeleton 一版一版往前推进。

❌
❌