Monorepo 里多个应用复用一套 AI 对话 SDK:我是怎么封装的 🧩

7 阅读2分钟

结论先放:在 pnpm monorepo 里,AI 对话这块逻辑别让每个应用各写一遍,抽成一个独立 package,把"传输层 / 状态层 / UI 层"切干净,复用率能上来,后面换模型供应商也只动一处。

背景

我们一个 monorepo 里塞了 4 个前端应用:后台、客服工作台、一个落地页、一个内部工具。陆陆续续都加了 AI 对话框,最早是各写各的——同样的"流式拼接 + 中断 + 重试"逻辑复制了三份。直到客服那边报了个 bug:流式中途断开后 token 拼错位。我去修,发现得改三个地方,当场决定抽包。

包的分层

最后落到 packages/chat-sdk,分三层导出:

chat-sdk/
  core/      纯逻辑,不依赖任何框架:发请求、解析 SSE、状态机
  react/     React hooks 封装(useChat)
  ui/        可选的对话气泡组件

core 我刻意写成框架无关,因为那个内部工具是 Vue 的。核心就一个状态机,对话的几个态说清楚:

type ChatState =
  | { status: 'idle' }
  | { status: 'streaming'; partial: string }
  | { status: 'done'; text: string }
  | { status: 'error'; reason: string }

SSE 解析单独拎出来,因为这块最容易出错——尤其一个 chunk 里可能含半个 JSON:

function feed(buffer: string, chunk: string) {
  buffer += chunk
  const lines = buffer.split('\n\n')
  const rest = lines.pop() ?? ''   // 最后一段可能不完整,留着下次拼
  return { events: lines.filter(Boolean), rest }
}

之前客服那个 bug 就是没留 rest,把半截 chunk 当完整事件解析了。

React 层就一个 hook

const { messages, send, stop, status } = useChat({
  endpoint: '/api/chat',
  onError: (e) => toast(e.reason),
})

每个应用接进来三行代码。endpoint 各填各的,传输层在 core 里统一。换模型、改鉴权头,全在 SDK 内部。

几个坑

  • peerDependencies 别乱写。我一开始把 react 写进 dependencies,结果两个应用 React 版本号差了个小版本,hooks 报 invalid hook call,查了俩小时。改成 peer 才好。
  • tree-shakingui 那层有应用根本不用,得保证 sideEffects: false,否则把对话气泡的样式也打进了那个落地页,包大了 12KB。
  • 还有个没解决干净的:core 里我用了原生 EventSource 的 polyfill 逻辑,SSR 时 window 不存在会炸,现在是粗暴地 typeof window 判断了一下,不优雅,待重构。

模型这端

SDK 把前端解耦了,但背后总得有模型在跑。我没自建推理,对话和测评直接调了讯飞的 MaaS(模型即服务)接口,多源大模型按场景切换,算力它兜,我这边 endpoint 配一下就完事。

你们 monorepo 里这种跨应用的能力都怎么抽包的?有没有人是直接发到私有 npm 而不是 workspace 引用的?评论区聊,我那个 SSR 判断想听听更干净的写法。