云计算百科
云计算领域专业知识百科平台

个人 AI 助手架构全揭秘:Monorepo + 插件系统 + 多通道网关设计,20 万行代码如何管理 6 个 IM 平台?

上一篇文章带大家体验了一个开源个人 AI 助手平台。很多人问:它到底是怎么设计的?为什么能同时接微信、企业 IM、社交平台,还能保持代码不失控?

答案藏在一个优雅的架构里——今天我们来拆解它。


一、项目整体概览

1.1 代码规模

src/
├── packages/ # 所有功能包
│ ├── core/ # 核心引擎
│ ├── gateway/ # 消息网关
│ ├── channels/ # 通道适配器
│ │ ├── wechat/
│ │ ├── dingtalk/
│ │ ├── telegram/
│ │ ├── slack/
│ │ ├── email/
│ │ └── sms/
│ ├── ai/ # AI 引擎
│ ├── plugins/ # 插件系统
│ ├── storage/ # 存储层
│ ├── scheduler/ # 定时任务
│ └── web/ # 管理后台
├── apps/ # 可部署的应用
│ ├── server/ # 主服务
│ └── cli/ # 命令行工具
├── config/ # 统一配置
├── scripts/ # 构建/部署脚本
└── pnpm-workspace.yaml # Monorepo 管理

总代码量约 20 万行 TypeScript,65 个 npm 包通过 pnpm workspace 联动。

1.2 Monorepo 设计思路

为什么选 Monorepo 而不是多仓库?

多仓库模式:
gateway-repo ──→ npm: @demo/gateway@1.2.0
ai-repo ──────→ npm: @demo/ai@0.9.0
wechat-repo ──→ npm: @demo/channel-wechat@0.5.0

── 问题:改一个接口要发 4 个包、更新 4 个仓库

Monorepo 模式:
demo-ai-assistant/
├── packages/gateway/ ←── 直接 import from "../ai"
├── packages/ai/ ←── 直接 import from "../core"
├── packages/channels/
└── packages/core/

── 改一个接口,所有引用方同时更新,一次 CI 验证


二、核心架构图

┌──────────────────────────────────────────────────────────────┐
│ 管理后台 (Web UI) │
│ http://localhost:3000/dashboard │
└────────────────────────────┬─────────────────────────────────┘
│ REST API
┌────────────────────────────┴─────────────────────────────────┐
│ 核心调度层 (Core Engine) │
│ │
│ ┌───────────────┐ ┌───────────────┐ ┌───────────────┐ │
│ │ MessageBus │ │ PluginMgr │ │ ScheduleMgr │ │
│ │ │ │ │ │ │ │
│ │ 消息路由分发 │ │ 插件生命周期 │ │ 定时任务 │ │
│ │ pub/sub │ │ 热加载/卸载 │ │ Cron 调度 │ │
│ └───────┬───────┘ └───────┬───────┘ └───────┬───────┘ │
│ │ │ │ │
│ ┌───────┴──────────────────┴───────────────────┴───────┐ │
│ │ Pipeline Engine │ │
│ │ │ │
│ │ Message → [PreProcess] → [Route] → [AI/Plugin] │ │
│ │ → [PostProcess] → [Reply] │ │
│ └───────────────────────────────────────────────────────┘ │
└───────────────────────────────────────────────────────────────┘
│ │ │
┌─────┴────┐ ┌─────┴─────┐ ┌──────┴──────┐
│ Channels │ │ AI Engine │ │ Storage │
│ 适配层 │ │ │ │ │
│ │ │ LLM 调用 │ │ SQLite/ │
│ 微信 │ │ 上下文 │ │ Redis │
│ 钉钉 │ │ RAG │ │ VectorDB │
│ Telegram │ │ 多模型 │ │ │
└──────────┘ └───────────┘ └─────────────┘


三、消息网关——多通道统一接入

这是整个平台最精妙的部分。不同 IM 的 SDK/API 千差万别,怎么把它们抽象成统一接口?

3.1 通道适配器接口

// packages/gateway/src/channel-adapter.ts

/**
* 所有消息通道必须实现的接口
* 每种 IM 平台只要实现这个接口,就能被网关识别
*/

export interface ChannelAdapter {
// === 生命周期 ===
/** 通道名称,全局唯一 */
readonly name: string;

/** 初始化(登录、建连) */
connect(config: ChannelConfig): Promise<void>;

/** 断开连接 */
disconnect(): Promise<void>;

/** 是否在线 */
isOnline(): boolean;

// === 消息收发 ===
/** 发送消息(统一出入口) */
send(target: MessageTarget, content: MessageContent): Promise<SendResult>;

/** 接收消息的回调注册 */
onMessage(handler: MessageHandler): void;

// === 通道特有 ===
/** 通道支持的消息类型 */
supportedTypes(): MessageType[];

/** 通道元信息 */
getMetadata(): ChannelMetadata;
}

3.2 微信通道实现

// packages/channels/wechat/src/wechat-adapter.ts
import type { ChannelAdapter, Message, MessageHandler } from '@demo/gateway';

export class WeChatAdapter implements ChannelAdapter {
readonly name = 'wechat';
private bot: WeChatBot | null = null;
private messageHandlers: MessageHandler[] = [];

async connect(config: WeChatConfig): Promise<void> {
// WeChat Bot SDK 初始化
this.bot = new WeChatBot({
name: config.botName || 'demo-bot',
// 扫码登录
onLogin: (user) => {
console.log(`微信登录成功: ${user.name()}`);
},
// 接收消息的统一入口
onMessage: async (rawMsg: WeChatMessage) => {
await this.handleIncomingMessage(rawMsg);
}
});

await this.bot.start();
}

async disconnect(): Promise<void> {
if (this.bot) {
await this.bot.logout();
this.bot = null;
}
}

isOnline(): boolean {
return this.bot?.isLoggedIn ?? false;
}

// 核心方法:原始消息 → 统一格式
private async handleIncomingMessage(rawMsg: WeChatMessage): Promise<void> {
// 格式化——将微信消息转成内部标准格式
const message: StandardMessage = {
id: rawMsg.id,
channel: 'wechat',
type: this.mapMessageType(rawMsg.type()),
from: {
id: rawMsg.talker().id,
name: rawMsg.talker().name(),
type: rawMsg.room() ? 'group' : 'individual',
},
room: rawMsg.room() ? {
id: rawMsg.room().id,
topic: await rawMsg.room().topic(),
} : undefined,
content: {
text: rawMsg.text(),
// 如果有引用回复
quote: rawMsg.quotedText() ?? undefined,
},
timestamp: rawMsg.date(),
raw: rawMsg, // 保留原始引用,供高级插件使用
};

// 分发给所有注册的消息处理器
for (const handler of this.messageHandlers) {
await handler(message);
}
}

async send(target: MessageTarget, content: MessageContent): Promise<SendResult> {
if (!this.bot) throw new Error('微信通道未连接');

let contact;
if (target.type === 'individual') {
contact = this.bot.Contact.find({ id: target.id });
} else {
contact = this.bot.Room.find({ id: target.id });
}

if (!contact) throw new Error(`目标未找到: ${target.id}`);

// 根据内容类型发送
if (content.type === 'text') {
await contact.say(content.text);
} else if (content.type === 'image') {
// 微信端图片处理
const fileBox = FileBox.fromUrl(content.url!);
await contact.say(fileBox);
}

return { success: true, timestamp: Date.now() };
}

onMessage(handler: MessageHandler): void {
this.messageHandlers.push(handler);
}

// 消息类型映射——微信特有类型 → 标准类型
private mapMessageType(wechatType: number): MessageType {
const typeMap: Record<number, MessageType> = {
7: 'text', // 文本
3: 'image', // 图片
6: 'file', // 文件
0: 'unknown',
};
return typeMap[wechatType] ?? 'unknown';
}

supportedTypes(): MessageType[] {
return ['text', 'image', 'file', 'unknown'];
}

getMetadata(): ChannelMetadata {
return {
name: this.name,
displayName: '微信',
version: '1.0.0',
capabilities: ['text', 'image', 'file', 'group', 'at'],
};
}
}


四、核心管道引擎——消息处理流水线

4.1 管道模式

消息进入 → [预处理器链] → [路由决策] → [AI/插件处理] → [后处理器链] → 回复
│ │ │ │
过滤垃圾消息 判断是否要回复 生成回复内容 格式化回复
去重 选择处理器 调用模型 发给目标通道
安全检测

4.2 管道实现

// packages/core/src/pipeline/pipeline-engine.ts

export interface PipelineStage {
name: string;
process(ctx: PipelineContext): Promise<PipelineContext>;
}

export class PipelineEngine {
private stages: PipelineStage[] = [];

constructor() {
// 注册内置处理阶段
this.stages = [
new PreProcessStage(), // 预处理
new RouteStage(), // 路由
new AIPluginStage(), // AI/插件
new PostProcessStage(), // 后处理
new ReplyStage(), // 发送回复
];
}

// 插件可以注入自定义阶段
addStage(stage: PipelineStage, before?: string): void {
const index = before
? this.stages.findIndex(s => s.name === before)
: this.stages.length;
this.stages.splice(index, 0, stage);
}

async execute(message: StandardMessage): Promise<ReplyResult> {
let ctx: PipelineContext = {
message,
metadata: {},
decisions: [],
reply: null,
errors: [],
};

for (const stage of this.stages) {
try {
const startTime = Date.now();
ctx = await stage.process(ctx);

// 如果某个阶段决定终止管道(如命中黑名单)
if (ctx.terminated) {
break;
}

ctx.metadata[`${stage.name}_duration`] = Date.now() startTime;
} catch (error) {
ctx.errors.push({
stage: stage.name,
error: error.message,
});
// 不终止,继续处理
}
}

return {
replied: ctx.reply !== null,
reply: ctx.reply,
pipelineLog: ctx.metadata,
errors: ctx.errors,
};
}
}

4.3 路由阶段——决定谁处理

// packages/core/src/pipeline/route-stage.ts

export class RouteStage implements PipelineStage {
name = 'route';

constructor(
private pluginManager: PluginManager,
private aiEngine: AIEngine,
private config: PipelineConfig
) {}

async process(ctx: PipelineContext): Promise<PipelineContext> {
const { message } = ctx;

// === 规则 1:检查是否需要回复 ===
// 群聊中只有被 @ 或包含关键词才回复
if (message.from.type === 'group') {
const mentioned = message.raw.mentionedSelf?.() ?? false;
const hasKeyword = this.config.listenKeywords.some(
kw => message.content.text?.includes(kw)
);

if (!mentioned && !hasKeyword) {
ctx.terminated = true;
return ctx;
}
}

// === 规则 2:检查黑名单 ===
if (this.config.blacklist?.users?.includes(message.from.id)) {
ctx.terminated = true;
return ctx;
}

// === 规则 3:插件匹配优先 ===
// 先遍历插件,看是否有匹配的触发器
for (const plugin of this.pluginManager.getActivePlugins()) {
if (plugin.triggers?.some(trigger => {
if (trigger instanceof RegExp) {
return trigger.test(message.content.text ?? '');
}
return message.content.text?.includes(trigger);
})) {
ctx.decisions.push({
handler: 'plugin',
handlerName: plugin.name,
priority: plugin.priority ?? 50,
});
}
}

// === 规则 4:知识库匹配 ===
if (ctx.config.enableKnowledge) {
const knowledgeMatch = await this.aiEngine.searchKnowledge(message);
if (knowledgeMatch && knowledgeMatch.score > 0.7) {
ctx.decisions.push({
handler: 'knowledge',
handlerName: knowledgeMatch.source,
priority: 40,
data: knowledgeMatch,
});
}
}

// === 规则 5:兜底给 AI ===
if (ctx.config.enableAI) {
ctx.decisions.push({
handler: 'ai',
handlerName: 'default-ai',
priority: 10,
});
}

// === 按优先级排序,选最高的 ===
ctx.decisions.sort((a, b) => b.priority a.priority);
ctx.metadata.selectedHandler = ctx.decisions[0];

return ctx;
}
}


五、插件系统——热插拔架构

5.1 插件接口设计

// packages/plugins/src/plugin-types.ts

export interface Plugin {
/** 唯一标识 */
name: string;

/** 显示名称 */
displayName: string;

/** 版本 */
version: string;

/** 描述 */
description?: string;

/** 触发条件——正则或字符串 */
triggers?: (string | RegExp)[];

/** 优先级,越高越先匹配 */
priority?: number;

/** 需要的配置项 */
schema?: PluginSchema;

/** 初始化 */
init?(config: PluginConfig): Promise<void>;

/** 核心执行 */
execute(message: StandardMessage, context: PluginContext): Promise<PluginResult>;

/** 销毁 */
destroy?(): Promise<void>;
}

export interface PluginContext {
/** 当前消息 */
message: StandardMessage;

/** 对话历史 */
history: Message[];

/** 用户画像 */
userProfile?: UserProfile;

/** 全局存储(跨请求) */
storage: PluginStorage;

/** 调用 AI 引擎 */
callAI(prompt: string, options?: AIOptions): Promise<string>;

/** 上一条插件结果(链式调用) */
previousResult?: PluginResult;
}

5.2 插件管理器

// packages/plugins/src/plugin-manager.ts

export class PluginManager {
private plugins: Map<string, Plugin> = new Map();
private activeState: Map<string, boolean> = new Map();
private eventBus: EventBus;

// 注册插件
async register(plugin: Plugin): Promise<void> {
if (this.plugins.has(plugin.name)) {
throw new Error(`插件 ${plugin.name} 已存在`);
}

this.plugins.set(plugin.name, plugin);
this.activeState.set(plugin.name, false);

this.eventBus.emit('plugin:registered', { name: plugin.name });
}

// 启用插件(热加载)
async enable(name: string, config?: PluginConfig): Promise<void> {
const plugin = this.plugins.get(name);
if (!plugin) throw new Error(`插件 ${name} 未注册`);

await plugin.init?.(config ?? {});
this.activeState.set(name, true);

this.eventBus.emit('plugin:enabled', { name });
console.log(`✅ 插件已启用: ${name}`);
}

// 禁用插件(热卸载)
async disable(name: string): Promise<void> {
const plugin = this.plugins.get(name);
if (!plugin) return;

await plugin.destroy?.();
this.activeState.set(name, false);

this.eventBus.emit('plugin:disabled', { name });
console.log(`⏸️ 插件已禁用: ${name}`);
}

// 获取所有激活的插件(按优先级排序)
getActivePlugins(): Plugin[] {
return Array.from(this.plugins.values())
.filter(p => this.activeState.get(p.name))
.sort((a, b) => (b.priority ?? 0) (a.priority ?? 0));
}
}

5.3 一个天气预报插件(完整实现)

// packages/plugins/demo-plugins/src/weather-plugin.ts
import { Plugin, PluginContext, PluginResult, PluginSchema } from '@demo/plugins';

export class WeatherPlugin implements Plugin {
name = 'demo-weather';
displayName = '天气预报';
version = '1.0.0';
description = '查询指定城市的天气信息,支持定时播报';
priority = 60;

triggers = [
/天气/,
/(今天|明天|后天).*(冷|热|温度|穿什么)/,
/.*天气预报.*/,
];

schema: PluginSchema = {
type: 'object',
properties: {
defaultCity: {
type: 'string',
description: '默认查询城市',
default: '深圳',
},
apiKey: {
type: 'string',
description: '天气 API 密钥',
sensitive: true,
},
},
};

private config: PluginConfig = {};

async init(config: PluginConfig): Promise<void> {
this.config = config;
}

async execute(message: StandardMessage, context: PluginContext): Promise<PluginResult> {
// 从消息中提取城市名
const city = this.extractCity(message.content.text ?? '')
|| this.config.defaultCity
|| '深圳';

try {
// 调用外部天气 API
const weather = await this.fetchWeather(city, this.config.apiKey);

// 生成自然语言回复——用 AI 润色
const prompt = `
将以下天气预报转成一句友好的回答:
城市:
${city}
天气:
${weather.condition}
温度:
${weather.temp}°C
湿度:
${weather.humidity}%
建议:
${weather.advice}

回复字数 80 字以内,口语化。
`;

const reply = await context.callAI(prompt, {
temperature: 0.7,
maxTokens: 120,
});

return {
reply,
metadata: {
city,
weather: {
condition: weather.condition,
temp: weather.temp,
humidity: weather.humidity,
},
},
consumed: false, // 不消费消息,允许后续继续处理
};
} catch (error) {
return {
reply: `抱歉,查询${city}天气失败: ${error.message}`,
error: error.message,
};
}
}

async destroy(): Promise<void> {
// 清理资源
}

private extractCity(text: string): string | null {
// 简单城市匹配——生产环境用 NER
const match = text.match(/(北京|上海|广州|深圳|杭州|成都|武汉|南京)/);
return match ? match[1] : null;
}

private async fetchWeather(city: string, apiKey: string): Promise<WeatherData> {
const response = await fetch(
`https://api.weather-demo.com/v1/forecast?city=${encodeURIComponent(city)}&key=${apiKey}`
);
if (!response.ok) throw new Error(`API 返回 ${response.status}`);
return response.json();
}
}

5.4 从文件系统动态加载插件

// packages/plugins/src/loader/fs-loader.ts
import { readdirSync, existsSync } from 'node:fs';
import { resolve } from 'node:path';
import { PluginManager } from '../plugin-manager';
import { Plugin } from '../plugin-types';

export class FileSystemPluginLoader {
constructor(
private pluginManager: PluginManager,
private pluginsDir: string
) {}

async loadAll(): Promise<number> {
if (!existsSync(this.pluginsDir)) {
console.warn(`插件目录不存在: ${this.pluginsDir}`);
return 0;
}

const entries = readdirSync(this.pluginsDir, { withFileTypes: true });
let loaded = 0;

for (const entry of entries) {
if (!entry.isDirectory()) continue;

const pluginPath = resolve(this.pluginsDir, entry.name);
const indexPath = resolve(pluginPath, 'index.ts'); // 或 index.js

if (!existsSync(indexPath)) {
console.warn(`插件 ${entry.name} 缺少入口文件`);
continue;
}

try {
// 动态 import
const module = await import(indexPath);
const plugin: Plugin = module.default ?? module.plugin;

if (!plugin || !plugin.name) {
console.warn(`插件 ${entry.name} 导出格式不正确`);
continue;
}

await this.pluginManager.register(plugin);

// 读取插件配置(默认启用)
const config = this.loadPluginConfig(entry.name);
if (config?.enabled !== false) {
await this.pluginManager.enable(plugin.name, config);
}

loaded++;
console.log(`📦 插件已加载: ${plugin.name} v${plugin.version}`);
} catch (error) {
console.error(`加载插件失败 ${entry.name}:`, error);
}
}

return loaded;
}

private loadPluginConfig(name: string): Record<string, any> {
const configPath = resolve(this.pluginsDir, name, 'config.yaml');
if (existsSync(configPath)) {
return parseYaml(readFileSync(configPath, 'utf-8'));
}
return {};
}
}


六、上下文管理——多轮对话如何记住历史

6.1 上下文窗口设计

// packages/ai/src/context-manager.ts

interface ContextWindow {
sessionId: string;
messages: ContextMessage[];
summary?: string; // 超过窗口时自动摘要
tokenCount: number;
maxTokens: number;
createdAt: Date;
updatedAt: Date;
}

export class ContextManager {
// === 两种缓存 ===
private activeWindows: Map<string, ContextWindow> = new Map(); // 热数据
private storage: ContextStorage; // 持久化(SQLite/Redis)

constructor(private config: ContextConfig) {}

async addMessage(sessionId: string, message: StandardMessage): Promise<void> {
let window = await this.getOrCreateWindow(sessionId);

const contextMessage: ContextMessage = {
role: 'user',
content: message.content.text ?? '',
timestamp: Date.now(),
};

window.messages.push(contextMessage);
window.tokenCount += this.estimateTokens(contextMessage.content);

// === 超过窗口大小 → 压缩成摘要 ===
if (window.tokenCount > window.maxTokens * 0.85) {
await this.compressWindow(window);
}

window.updatedAt = new Date();
await this.storage.save(sessionId, window);
}

private async compressWindow(window: ContextWindow): Promise<void> {
// 保留最近 5 条消息,其余压缩为摘要
const compressMessages = window.messages.slice(0, 5);
const recentMessages = window.messages.slice(5);

const summary = await this.aiEngine.summarize(
compressMessages.map(m => `${m.role}: ${m.content}`).join('\\n')
);

window.summary = summary;
window.messages = [
{
role: 'system',
content: `之前的对话摘要:${summary}`,
timestamp: Date.now(),
},
recentMessages,
];

window.tokenCount = this.estimateTokens(summary)
+ recentMessages.reduce((s, m) => s + this.estimateTokens(m.content), 0);
}

// 多用户隔离
private buildSessionId(message: StandardMessage): string {
// 同一个用户 + 同一个群的对话共享上下文
if (message.room) {
return `room:${message.room.id}:user:${message.from.id}`;
}
return `user:${message.from.id}`;
}
}


七、最后——这张架构图的几点精髓

回顾整张架构图,最值得学习的三个设计决策:

7.1 通道适配器模式

ChannelAdapter 接口 ← 微信 Adapter
← 钉钉 Adapter
← Telegram Adapter
← … 任意 IM

核心引擎只依赖接口,不关心具体实现
新加一个 IM 通道 = 实现一个 Adapter,零修改核心代码

7.2 管道 + 插件双模式

管道模式:处理"怎么处理一条消息"的固定流程
→ 预处理 → 路由 → 处理 → 后处理 → 回复

插件模式:提供"具体怎么处理"的可扩展能力
→ 天气插件 / 翻译插件 / 日程插件 / …

管道负责流程,插件负责策略。
管道不关心有几个插件,插件不关心怎么被调用的。

7.3 上下文管理的两级缓存

热数据(内存 Map) ← 毫秒级访问,高 QPS

│ 定期同步

冷数据(SQLite/Redis) ← 持久化,可恢复

这是性能和可靠性之间的经典权衡,在高并发场景下至关重要。


赞(0)
未经允许不得转载:网硕互联帮助中心 » 个人 AI 助手架构全揭秘:Monorepo + 插件系统 + 多通道网关设计,20 万行代码如何管理 6 个 IM 平台?
分享到: 更多 (0)

评论 抢沙发

评论前必须登录!