三端架构与 Monorepo

要点

  • 前面的文章里,我们完成了 AI 伴侣项目的理论基础和技术选型:CloudFlare Workers 作为基础设施,Hono.js 作为后端框架
  • 一个完整的 AI 伴侣产品,至少需要三个独立的子系统
  • 确定了三个子系统之后,下一个决策是:它们应该放在一个仓库(Monorepo)还是三个独立仓库(Multirepo)
  • 确定了 Monorepo 方案后,需要一个工具来管理多项目的构建、测试和开发流程
  • 回顾一下整个系统的架构关系

内容

1. 从技术选型到工程落地

前面的文章里,我们完成了 AI 伴侣项目的理论基础和技术选型:CloudFlare Workers 作为基础设施,Hono.js 作为后端框架,Next.js 作为前端框架。每一个选择都有充分的理由,但选型只是起点——把这些技术组合成一个可维护、可协作、可持续演进的工程项目,才是真正的挑战。

这篇文章要回答的核心问题是:一个 AI 伴侣产品,在工程层面到底需要哪些子系统?它们之间的关系是什么?以及——我们为什么要把它们放在同一个代码仓库里管理?

2. 三个子系统的职责划分

一个完整的 AI 伴侣产品,至少需要三个独立的子系统。

2.1 Hono 服务端——AI 对话的中枢神经

服务端是整个系统的核心,承担所有与 AI 相关的逻辑编排。它不做推理,但它决定了推理的质量。

对话管线。 这是服务端最核心的职责。一条用户消息从进入到返回,经历完整的处理链路:接收消息 → 安全检查 → 身份验证 → 记忆检索(从 D1 和 Vectorize 中拉取相关记忆)→ 情绪状态读取(从 KV 中获取当前情绪)→ Prompt 组装(将记忆、情绪、人设、对话历史拼接成完整的 Prompt)→ LLM 调用(流式请求)→ 响应解析(提取情绪变化、记忆触发点)→ 记忆写回 → 情绪状态更新 → 流式返回给客户端。

这条管线中的每一步,我们在前面的文章中都分别讨论过。服务端的工作就是把它们串起来。

用户认证与鉴权。 JWT 签发与验证、用户注册登录、会话管理。服务端需要区分两类调用者:普通用户(通过客户端访问)和管理员(通过后台管理系统访问),并为它们提供不同权限级别的 API。

记忆系统 CRUD。 围绕 D1(关系型存储)、Vectorize(向量检索)、KV(快速读写)构建的记忆读写接口。包括情景记忆的存储与检索、语义记忆的归纳与更新、用户偏好的读写。

情绪状态管理。 维护 AI 伴侣的情绪状态机,提供情绪查询和情绪转移的 API。

后台管理接口。 为 admin 系统提供数据查询和系统配置的 API。这部分 API 需要更高的权限验证。

2.2 Next.js 客户端——用户直接触达的产品界面

客户端是用户每天打开的那个应用,它的核心是对话体验

聊天界面。 实时对话窗口,支持流式消息展示(逐字输出效果)、消息气泡、时间戳、情绪标签展示。这是产品的核心交互,需要极致的响应速度和流畅的动画效果。

用户注册与登录。 账号体系的前端部分,支持邮箱注册、第三方登录等。登录后维护 JWT token 的存储和自动刷新。

个人设置。 用户可以自定义 AI 伴侣的性格特征(温柔/理性/幽默等)、设置记忆偏好(哪些话题更重要、是否允许记住某些信息)、管理对话历史。

SSR/SSG 落地页。 产品介绍页、功能说明页、定价页等。这些页面需要 SEO 友好,适合用 Next.js 的 SSG 能力生成静态页面。对话功能本身是纯客户端交互,但产品的入口页面需要被搜索引擎收录。

记忆回顾。 用户可以查看 AI 伴侣记住了哪些关于自己的信息,可以手动删除或修正某些记忆。这是建立用户信任的重要功能。

2.3 Next.js 后台管理系统——运营与维护的控制台

后台管理系统面向产品运营者和开发者,用于监控系统状态、管理内容、调优 AI 行为。

用户数据总览。 活跃用户数、日均对话轮次、记忆使用量、用户留存率等核心指标的可视化看板。这些数据帮助运营者理解产品健康度。

对话审计与内容安全。 查看用户与 AI 的对话记录(脱敏后),识别可能的不当内容、检查 AI 回复质量。这是合规要求,也是产品质量保障的手段。

Prompt 模板管理。 在线编辑 AI 伴侣的系统 Prompt、人设描述、情绪响应模板。支持版本管理(回滚到之前的版本)和 A/B 测试(同时运行两套 Prompt,对比效果)。这是 AI 产品最核心的运营工具——不改代码,只调 Prompt,就能显著改变 AI 的行为。

系统配置。 LLM 模型切换(GPT-4o / Claude 3.5 / 开源模型的切换)、记忆策略调整(遗忘曲线参数、记忆容量上限)、情绪状态机参数调优(情绪转移的阈值和速率)。

运维监控。 API 请求延迟、错误率、Workers CPU 用量、D1 查询性能等技术指标。当系统出现异常时,运营者需要第一时间知道。

2.4 为什么客户端和后台要分成两个 Next.js 应用

一个常见的问题是:既然都是 Next.js,为什么不做成一个应用,用路由区分 /app/admin

用户群体完全不同。 客户端面向终端用户,可能有数万到数百万的访问量;后台面向几个到几十个管理员。它们的性能优化策略、缓存策略、部署策略完全不同。

安全边界。 后台管理系统包含敏感数据(用户对话、系统配置),必须有独立的访问控制。放在同一个应用里,一旦客户端出现 XSS 漏洞,攻击者可能通过路由跳转接触到后台功能。独立部署意味着独立的域名和独立的安全策略。

构建与部署独立性。 客户端更新频繁(UI 迭代、新功能上线),后台管理系统相对稳定。独立的构建流程意味着客户端的发布不会影响后台的稳定性,反之亦然。

包体积控制。 后台管理系统会引入大量的图表库(ECharts / Recharts)、表格组件、富文本编辑器等重型依赖。这些依赖不应该出现在客户端的 bundle 中。

3. 为什么放在一个仓库

确定了三个子系统之后,下一个决策是:它们应该放在一个仓库(Monorepo)还是三个独立仓库(Multirepo)?

我们选择 Monorepo。这不是跟风,而是这个项目的特性决定的。

3.1 类型共享是第一驱动力

在第 10 篇文章中,我们讨论了 Hono.js 的 RPC 类型安全调用——前端调用后端 API 时,请求参数和响应类型都是自动推导的,不需要手动定义共享类型。

但这个能力有一个前提:前端必须能直接 import 后端的类型定义。

// types.ts
// 服务端定义路由
 
const route = app.post('/chat',
 
  zValidator('json', chatSchema),
 
  async (c) => {
 
    const body = c.req.valid('json')
 
    return c.json({ reply: '...', emotion: 'happy' })
 
  }
 
)
 
// 导出类型
 
export type AppType = typeof route
// client.ts
// 客户端直接 import 服务端类型——这要求两者在同一个仓库
 
import type { AppType } from '@ai-companion/server'
 
import { hc } from 'hono/client'
 
const client = hc<AppType>('/api')
 
const res = await client.chat.$post({ json: { content: '你好' } })
 
// res 的类型自动推断,写错参数 TypeScript 直接报错

如果 server 和 web 在不同的仓库,这个 import type 就无法直接工作。你需要:

  1. 在 server 仓库中手动导出类型到一个 npm 包
  2. 发布这个包到 npm(或私有 registry)
  3. 在 web 仓库中安装这个包
  4. 每次 server 的 API 变更,都要重新发布、重新安装

这个流程不仅繁琐,而且破坏了 Hono RPC 的核心价值——它的类型推导依赖于直接引用服务端的路由定义,经过 npm 包中转后,类型信息会丢失或滞后。

Monorepo 下,import type 直接指向源码。API 改了,TypeScript 编译器立刻在所有调用方标红。这是零成本的端到端类型安全。

3.2 共享业务逻辑

三个子系统之间存在大量需要保持一致的业务逻辑。

Zod Schema 复用。 服务端用 Zod 做运行时参数验证,客户端用同一份 Zod Schema 做表单验证,后台用同一份 Schema 验证配置输入。一份 Schema,三处使用,改一处全部同步。

// schema.ts
// packages/shared/src/schema.ts
 
import { z } from 'zod'
 
export const chatRequestSchema = z.object({
 
  content: z.string().min(1).max(2000),
 
  session_id: z.string().uuid(),
 
})
 
export const emotionSchema = z.enum([
 
  'happy', 'sad', 'neutral', 'excited',
 
  'anxious', 'calm', 'curious', 'angry',
 
  'tender', 'playful'
 
])
 
export const memoryTypeSchema = z.enum([
 
  'episodic', 'semantic', 'preference'
 
])
 
// TypeScript 类型从 Zod 推断,不需要手动定义
 
export type ChatRequest = z.infer<typeof chatRequestSchema>
 
export type Emotion = z.infer<typeof emotionSchema>
 
export type MemoryType = z.infer<typeof memoryTypeSchema>

如果这些定义分散在三个仓库中,你必须手动保持同步。新增一种情绪状态时,你需要在三个仓库中分别修改、分别提交、分别发布。 只要有一个仓库忘了更新,就会出现运行时错误。

错误码定义。 服务端抛出的错误码,客户端需要据此展示对应的用户提示,后台需要据此做错误分类统计。三方必须使用同一份错误码映射表。

工具函数。 日期格式化(对话时间显示)、文本截断(记忆预览)、ID 生成等通用逻辑,三端都会用到。

3.3 原子提交保证版本一致

API 变更是最容易出问题的环节。假设我们要给对话接口新增一个 emotion_hint 字段:

Monorepo 下:

一次 commit 同时修改 server(新增字段处理)、shared(更新 Schema)、web(在 UI 中展示)、admin(在审计中展示)。代码审查者在一个 PR 中看到完整的变更链路,确认无遗漏后合并。要么全部更新,要么都不更新。

Multirepo 下:

  1. 先在 shared 仓库更新 Schema,发布新版本
  2. 再在 server 仓库更新依赖,实现新字段逻辑,部署
  3. 再在 web 仓库更新依赖,实现 UI 展示,部署
  4. 再在 admin 仓库更新依赖,实现审计展示,部署

这四步之间存在时间差。如果 server 已经部署了新版本但 web 还没更新,用户端就会收到一个它不认识的字段。如果 shared 更新了但 server 忘了升级依赖,运行时类型就不匹配。

版本同步问题在 Multirepo 中是结构性的——它不是"小心一点就能避免"的问题,而是架构本身引入的复杂度。

3.4 统一工具链与开发体验

一个仓库意味着一套配置。 一份 tsconfig.json(基础配置),各子项目 extends 它;一份 Biome 配置,统一代码风格;一份 yarn.lock,依赖版本全局一致;一套 CI/CD 配置,自动检测哪些子项目受当前 PR 影响。

多仓库下,你需要在每个仓库中维护独立的配置。当你决定升级 TypeScript 版本、修改 lint 规则、或者统一代码格式时,需要分别在三个仓库中操作。配置漂移(drift)几乎不可避免——三个月后,三个仓库的 tsconfig 可能已经有微妙的差异,而没人注意到。

本地开发的差距更直观。 Monorepo 下,开发者克隆一个仓库,运行一条命令,三个子系统同时启动。修改 server 的 API,web 的类型检查立刻报错,提示你需要同步更新客户端代码。反馈环路是即时的。

多仓库下,你需要分别克隆三个仓库,分别安装依赖,分别启动。调试一个跨前后端的问题时,你需要在三个终端之间来回切换。如果本地需要测试 shared 包的修改,你还需要用 yarn linkyalc 做本地包链接——这些工具经常出问题。

3.5 与多仓库方案的正面对比

维度MonorepoMultirepo
类型共享直接 import,零延迟需要发包、安装、版本对齐
API 变更同步原子提交,一个 PR 搞定多仓库协调,存在时间差
工具链配置一份配置,全局统一多份配置,容易漂移
本地开发一条命令启动所有服务多终端、多仓库、需要 link
CI/CD一套流水线,按变更范围触发多套流水线,跨仓库触发复杂
代码审查一个 PR 看到完整变更跨仓库 PR 关联困难
仓库体积较大(但可接受)各自较小
权限控制较粗(需要 CODEOWNERS)天然隔离

多仓库的优势——仓库体积小、权限天然隔离——在我们的场景中并不关键。AI 伴侣项目的代码量不会大到 Monorepo 无法承受(那是 Google/Meta 级别的问题),权限控制可以通过 GitHub 的 CODEOWNERS 文件解决。

而 Monorepo 的优势——类型共享、版本同步、开发体验——恰恰是我们最需要的。

4. Monorepo 工具选型:Turborepo

确定了 Monorepo 方案后,需要一个工具来管理多项目的构建、测试和开发流程。主流选择有三个:Turborepo、Nx、Lerna。

4.1 为什么选 Turborepo

与 Next.js 同属 Vercel 生态。 Turborepo 由 Vercel 维护,与 Next.js 的集成是一等公民级别的。它天然理解 Next.js 的构建输出(.next 目录)、开发服务器、以及增量构建。对于我们这种包含两个 Next.js 应用的项目,这种集成深度意味着更少的配置和更少的坑。

极简配置。 Turborepo 的核心理念是"约定大于配置"。一个 turbo.json 文件定义任务依赖关系,就可以工作了。相比之下,Nx 功能更强大但配置也更复杂,对于我们三个子项目的规模,Nx 的很多能力用不上。

// turbo.json
{
 
  "tasks": {
 
    "build": {
 
      "dependsOn": ["^build"],
 
      "outputs": [".next/**", "dist/**"]
 
    },
 
    "dev": {
 
      "persistent": true,
 
      "cache": false
 
    },
 
    "typecheck": {
 
      "dependsOn": ["^build"]
 
    },
 
    "lint": {}
 
  }
 
}

这几行配置表达了:build 任务需要先构建依赖包(^build),dev 是持久化运行的开发服务器不需要缓存,typecheck 依赖于依赖包的构建产物,lint 可以完全并行。

增量构建与远程缓存。 Turborepo 会缓存每个任务的输入输出。如果你只改了 apps/web 的代码,apps/adminapps/server 的构建会直接命中缓存,跳过执行。团队协作时,还可以开启远程缓存(Remote Cache),A 同事构建过的产物,B 同事直接复用。

与 yarn workspaces 天然配合。 Turborepo 不替代包管理器,而是在 yarn workspaces 之上提供任务编排能力。yarn workspaces 负责依赖安装和包之间的链接,Turborepo 负责任务的并行执行和缓存。各司其职。

4.2 与其他方案的简要对比

Nx 功能更全面(内置代码生成器、依赖图可视化、受影响项目检测),适合大型团队和超大型 Monorepo(数十个子项目)。但对于三个子项目的规模,它的学习成本和配置复杂度是过度的。

Lerna 是最早流行的 Monorepo 工具,但它主要解决的是"多 npm 包发布"的问题。我们的三个子项目是应用(不需要发布到 npm),不是库。Lerna 的核心能力(版本管理、发布流程)在我们的场景中用不上。

NOTE

Turborepo 不是"最强大"的工具,但它是最匹配我们场景的工具。三个子项目、TypeScript 全栈、Next.js 为主——这正是 Turborepo 的甜点区。

5. 整体架构图

回顾一下整个系统的架构关系:

// architecture.txt
┌─────────────────────────────────────────────────────┐
 
│                   Monorepo (Turborepo)               │
 
│                                                      │
 
│  ┌──────────────────── apps ────────────────────┐   │
 
│  │                                               │   │
 
│  │  ┌─────────┐  ┌─────────┐  ┌─────────────┐  │   │
 
│  │  │ server  │  │   web   │  │    admin    │  │   │
 
│  │  │ (Hono)  │  │(Next.js)│  │  (Next.js)  │  │   │
 
│  │  └────┬────┘  └────┬────┘  └──────┬──────┘  │   │
 
│  │       │             │              │          │   │
 
│  └───────┼─────────────┼──────────────┼──────────┘   │
 
│          │             │              │              │
 
│  ┌───────┼─────────────┼──────────────┼──────────┐   │
 
│  │       ▼             ▼              ▼          │   │
 
│  │              packages                         │   │
 
│  │  ┌─────────┐  ┌─────────┐  ┌─────────────┐  │   │
 
│  │  │ shared  │  │   ui    │  │   config    │  │   │
 
│  │  │(类型/   │  │(共享UI  │  │(tsconfig/   │  │   │
 
│  │  │ Schema) │  │ 组件)   │  │  biome)     │  │   │
 
│  │  └─────────┘  └─────────┘  └─────────────┘  │   │
 
│  └───────────────────────────────────────────────┘   │
 
│                                                      │
 
└─────────────────────────────────────────────────────┘
 
部署目标:
 
  server  → CloudFlare Workers
 
  web     → Vercel / CloudFlare Pages
 
  admin   → 内部部署 / Vercel(访问控制)

三个应用共享底层的 packages,通过 Turborepo 编排构建和开发流程,最终分别部署到各自的目标平台。

6. 总结

这篇文章回答了三个问题:

需要哪些子系统? Hono 服务端(AI 对话中枢)、Next.js 客户端(用户界面)、Next.js 后台管理(运营控制台)。三者职责清晰、用户群体不同、部署策略独立。

为什么放在一个仓库? 类型共享是第一驱动力。Hono RPC 的端到端类型安全、Zod Schema 的三端复用、API 变更的原子提交——这些核心价值只有在 Monorepo 下才能真正实现。

用什么工具管理? Turborepo + yarn workspaces。与 Next.js 生态深度集成,配置极简,增量构建缓存开箱即用。

蓝图有了。下一篇文章,我们将进入 Monorepo 的内部,设计 packages/sharedpackages/ui 的具体结构,展开公共逻辑的抽取方案,并规划完整的开发工作流。