本文是掘金发布稿。发布时把
docs/screenshots/里的图片上传到掘金图床,替换文中的图片占位标记即可。
从 0 到 1 用 NestJS + LangChain 搭一套可售卖的 SaaS AI 客服:挂件一行代码接入,知识库秒变 SEO 帮助中心
TL;DR:我用 NestJS 11 + Vue3 + LangChain 1.x + Prisma + PostgreSQL 做了一套多租户 SaaS AI 客服系统 。企业自助注册 3 秒开通专属工作空间,导入知识库后把一行
<script>贴到自己官网,右下角就有 AI 客服气泡;同一批知识同时输出成可被百度/Google 收录的帮助中心页面。后端 64 个单测 + e2e 全绿,真机闭环验证:注册 → 建库 → 发文 → 挂件问答引用知识库内容 → 越权访问被服务端拦截。
【图片占位:docs/screenshots/widget-demo.png ------ 挂件实拍,AI 基于知识库回答】
一、为什么做这个,以及底子是什么
做 AI 客服的产品很多,但大部分是"要么全托管 SaaS 你没法私有化,要么开源项目你没法改"。我的思路是:先有一个工程化过硬的底座,再往上长出产品。
底座是我维护的一个 NestJS 后端框架,之前已经具备:
- 细粒度权限中枢(RBAC 权限码,接口级
@RequirePermissions、按钮级v-auth、数据行级@DataScope,前后端同一套码); - AI 基础设施(多厂商可切换、SSE 流式、调用审计落库);
- JWT 双 Token、Redis 缓存、统一响应、结构化日志这一整套横切能力。
这次要做的是把它从"框架"变成"产品":多租户、注册开通、知识库、C 端挂件、SEO 页面。事实证明这个决策非常划算------权限、审计、前端组件范式全部复用,我只需要专注写"客服"这件事本身。
二、整体产品闭环
先把闭环画清楚,避免做成一堆散功能:
xml
企业自助注册 → 开通租户+管理员 → 搭知识库、发文章打 TAG
↓ 复制挂件代码 / 分享帮助中心链接
客户官网:<script src="平台/widget.js?k=sk-xxx"> → 右下角 AI 客服
↓ 自然流量入口
搜索引擎:抓取 /help/{租户}/{slug}(服务端直出,含 JSON-LD)
↓
AI:检索租户知识库 Top-N → 注入 Prompt → 流式回答(全程审计落库)
↓ 答不上
兜底:如实告知 + 引导转人工/留资
↓
平台方:全租户会话记录 + 用量审计 → P3 按量计费
下面挑五个最有讲头的设计展开。
三、多租户:不是加个 tenantId 就完了
3.1 数据模型
四张核心表(Prisma,全部软删除 + snake_case 映射):
prisma
model Tenant {
id String @id @default(uuid())
name String
code String @unique // 帮助中心域名段 /help/{code}
ownerId String? @map("owner_id") // ★ 自助注册的管理员,隔离锚点
status Status @default(ENABLED)
// ... apiKeys / conversations / kbs 关系
}
ownerId 是整个隔离方案的锚:租户和它的管理员账号互为归属,一个字段同时解决了"这个用户能看什么"和"这个租户归谁管"。
3.2 服务端行级隔离(重点:不是前端藏按钮)
很多多租户 Demo 只做了"列表传个 tenantId 参数",那是假隔离。我的做法是三层递进:
第一层,登录守卫自动注入上下文:
typescript
// JwtAuthGuard.loadUser()
const ownedTenant = user.isSuper ? null : (
await this.prisma.tenant.findFirst({
where: { ownerId: userId, deletedAt: null },
select: { id: true },
})
)?.id ?? null;
request.user = { ...user, tenantId: ownedTenant ?? null };
第二层,一个 10 行的工具函数定义"谁被限制":
typescript
// scope.util.ts:超管/平台员工返回 null(不限制),租户管理员返回其租户 ID
export function dataScopeOf(user: AuthUser): string | null {
return user.isSuper ? null : (user.tenantId ?? null);
}
第三层 ,所有涉及租户数据的服务方法强制套用,且越权一律按"不存在"处理(404/资源不存在,而不是 403------不泄漏其他租户资源的存在性):
typescript
async requireKb(id: string, scope: string | null) {
const kb = await this.prisma.saasKb.findFirst({ where: { id, deletedAt: null } });
if (!kb || (scope && kb.tenantId !== scope)) {
throw new BusinessException(ErrorCode.NOT_FOUND, '知识库不存在');
}
return kb;
}
真机验证(节选):
bash
# 租户管理员指定他人租户建库 → 40301 只能为自己的租户创建知识库
# 库列表带他人 tenantId 过滤参数 → 仍只见自己的 1 个库
# 操作 demo 租户的 API Key → 30101 API Key 不存在
# 平台 admin 视角 → 同时看到 tiyan-shop, demo-shop 两个租户
【图片占位:docs/screenshots/saas-tenant.png ------ 租户管理页】
四、AI 层:上 LangChain 1.x,但门面留在自己手里
4.1 架构取舍
中间我把原来手写的工具调用循环整个换成了 LangChain,但没有 直接 new ChatOpenAI() 让业务层裸调,而是保留了一个很薄的门面层:
markdown
业务模块 ──→ AiGateway(流式/降级/审计门面)
──→ AiAgentService(createAgent 工具编排)
↓
AiRegistry(DB 模型配置 → ChatOpenAI/ChatOllama 实例,按 updatedAt 缓存)
这样三个既有产品能力一条没丢:主→备模型自动降级 、每次调用落 ai_call_log 审计 、模型配置页改完即时生效 (不用重启)。ESLint 直接把 langchain、@langchain/* 加进 no-restricted-imports,业务层想裸调框架都过不了 lint。
4.2 Agent 工具:zod 声明,权限折叠在 infra
9 个管理端工具(查用户、建用户、配权限......)从手写 JSON Schema 换成 zod 唯一事实来源:
typescript
{
name: 'create_user',
description: '创建新用户(添加员工)。调用前必须已获得用户确认。',
schema: z.object({
username: z.string().regex(/^[a-zA-Z0-9_]{2,32}$/).describe('登录账号'),
nickname: z.string().describe('显示昵称,如:张三'),
// ...
}),
permission: PermissionCode.SystemUserCreate,
run: async (args, ctx) => { /* 真实业务,不变 */ },
}
关键设计是权限折叠 :工具执行失败(无权限/业务校验不过)绝不能让整个 Agent run 崩掉,而是折叠成 {error} JSON 回喂模型,让模型用自然语言向用户解释:
typescript
export async function executeToolSafely(spec, args, ctx) {
if (spec.permission && !ctx.user.isSuper && !ctx.perms.has(spec.permission)) {
return JSON.stringify({ error: `权限不足,执行该操作需要 ${spec.permission} 权限` });
}
try {
return JSON.stringify(await spec.run(args, ctx) ?? null);
} catch (error) {
return JSON.stringify({ error: error.message });
}
}
SSE 帧契约 {delta, done, tool:{name,status}, usage, model} 与改造前完全一致,前端一行没改。
4.3 踩坑实录(LangChain 1.x)
- 没有
.bind()了 :core v1 的 Runnable 移除了 bind,模型级参数(model/max_tokens)改为按模型行构造实例并缓存,别想着运行时绑定了; baseURL要放configuration里 :new ChatOpenAI({ configuration: { baseURL } }),直接传会类型报错;- 工具事件名在
event.name,不在event.data.name(data 里只有 input); - 用量在
on_chat_model_end:agent 跑一轮on_llm_end根本不触发,要用 chat model 的结束事件从usage_metadata里取。
五、知识库检索:没 embedding 也能打
市面上讲 RAG 张口就是向量化,但现实是:很多国产模型没有 embedding 接口,pgvector 在生产 PG 上也未必装得上。我的选择是 P1 先用 PG 关键词检索,把检索服务抽象成接口,P2 无缝换 pgvector。
核心是中文分词------PG 的 ILIKE '%词%' 对英文单词天然友好,对中文长句不行。方案:重叠二字切片:
typescript
// "怎么开发票呢" → 怎么 / 么开 / 开发 / 发票 / 票呢(步长 1,避免跨边界漏词)
for (let i = 0; i + 2 <= part.length && out.length < 12; i += 1) {
out.push(part.slice(i, i + 2));
}
切片做 ILIKE 召回(候选上限 60),再在 JS 侧打分:标题命中 > 正文命中 > 整句命中,取 Top-3 注入 system prompt。实测效果:
【图片占位:docs/screenshots/saas-kb.png ------ 知识库管理页】
访客问"退货运费由谁承担?",AI 回答(截图实拍):
退货运费的承担方要看退货原因:
- 质量问题 → 我们承担退货运费
- 非质量问题(如不想要、拍错等)→ 由买家承担退货运费 如有退货需求,请在收货后 7 天内联系在线客服申请。
回答里的每一条都来自知识库文章,不是模型编的------v2 Prompt 模板明确要求"仅依据参考资料回答,资料没有的如实说并引导人工"。
六、网页挂件:一行 script 背后的三个决定
6.1 iframe 隔离,而不是注入 DOM
客户网站的 CSS 什么样你控制不了,div 直接注入必然被污染。挂件 = 气泡按钮(原生 div,样式内联)+ iframe 容器页(对话 UI 全在 iframe 里),样式和 JS 完全隔离。
widget.js 从自身 <script src> 解析 Key 和平台地址,客户连配置都不用写:
javascript
var sc = document.currentScript;
var url = new URL(sc.src);
var KEY = url.searchParams.get('k');
var BASE = url.origin;
6.2 helmet 默认头会杀了你的挂件
这里踩了个真坑:helmet 默认所有响应带 Cross-Origin-Resource-Policy: same-origin,浏览器直接拦截跨源加载挂件脚本,气泡根本不出现。修法是只对挂件相关路由定点放开:
typescript
app.use((req, res, next) => {
if (req.path === '/widget.js' || req.path === '/chat-widget') {
res.setHeader('Cross-Origin-Resource-Policy', 'cross-origin');
}
if (req.path === '/chat-widget') {
res.removeHeader('X-Frame-Options');
res.setHeader('Content-Security-Policy', 'frame-ancestors *');
}
next();
});
注意是定点放开,其余接口安全基线一律不动。
6.3 Key 的安全姿势
明文 Key 只在创建时返回一次(弹窗里直接给"复制挂件代码"),库里存 sha256;对话走 X-Api-Key 请求头 + 限流;Key 失效前端有友好提示。
【图片占位:docs/screenshots/help-center.png ------ C 端 SEO 帮助中心】
七、SEO 帮助中心:不建 SSR 工程的直出方案
知识文章要吃自然流量,页面必须服务端直出。方案选型时我否掉了两个方向:再建一个 Nuxt 工程(双倍维护成本)、引入 ejs/nunjucks(多一个模板引擎依赖)。最终方案是 TypeScript 模板函数直出 HTML ------零新依赖、类型安全、随 nest build 产出。
三个要点:
- XSS 防护从函数设计入手 :
markdownToHtml先对全文escapeHtml,再做白名单标签替换,用户内容物理上不可能注入脚本(有单测盯着); - JSON-LD 双结构 :每篇文章输出
Article+FAQPage两份 schema.org 结构化数据(约定文章里## 问句+ 段落即 FAQ),搜索结果有机会出富摘要; - 完整 SEO 套件 :canonical、Open Graph、
sitemap.xml、robots.txt,全部服务端生成。
验证方式很朴素:curl 直接抓页面,禁 JS 的爬虫能看到完整正文。
八、注册闭环:3 秒开通一个租户
POST /auth/register 一个接口一次事务,完成"开通租户 + 创建管理员 + 分配租户管理员角色":
typescript
await this.prisma.$transaction(async (tx) => {
const user = await tx.sysUser.create({ data: { username, password: hash, ... } });
await tx.tenant.create({ data: { name, code, ownerId: user.id, ... } });
await tx.sysUserRole.create({ data: { userId: user.id, roleId: tenantAdminRole.id } });
});
tenant_admin 角色由 seed 固定维护(知识库/会话/自家 Key 全套权限,没有租户增删改权限),注册接口限流 3 次/分钟,租户编码/账号重复分别返回明确错误码。忘记密码没做邮件通道(本地部署场景),改成引导页提示联系平台管理员------比放一个假表单诚实。
【图片占位:docs/screenshots/auth-register.png ------ 注册页】
九、工程化:怎么保证改不坏
- 64 个单测(12 套):门面降级、Agent 帧映射、工具权限折叠、zod 校验、注册事务、租户隔离(含"越权按不存在处理"的语义断言);
- e2e +
madge循环依赖检查 +vue-tsc全量类型检查; - 真机闭环冒烟当作验收标准:注册 → 登录 → 建库 → 发文 → 帮助中心抓取验证 JSON-LD → 挂件问答引用知识库 → 越权接口逐一确认被拦;
- AGENTS.md 把架构约定写死(AI 只准注入门面、LangChain 只准出现在
infra/ai),AI 编码代理和人一起干活也不会把边界改散。
十、现在的样子和接下来的路
【图片占位:docs/screenshots/dashboard.png ------ 工作台】 【图片占位:docs/screenshots/saas-conversation.png ------ 访客会话】
已完��:多租户地基、自助注册、知识库+TAG、C 端 SEO 帮助中心、网页挂件、LangChain Agent、个人中心,前后端全绿已推送。
路线图:
- P2 人机协作:低置信度/关键词触发转人工、坐席工作台(实时接管会话)、满意度评价、工单;
- P3 商业化:套餐(功能开关 + 配额)、按量计费(审计表已有成本口径)、账单;
- P4 增长:微信公众号/小程序渠道适配、未命中问题挖掘、Prompt A/B。
写在最后
这套系统最有意思的地方不在某个技术点,而在每一层都是"可售卖"倒推出来的设计:隔离按"不能看到别人的数据"设计,检索按"没有 embedding 也能上线"设计,挂件按"客户只贴一行代码"设计,SEO 按"爬虫禁 JS 也能读"设计。技术选型服务于商业闭环,才是做产品的姿势。
项目完整代码在仓库 yhemeo613/nest-admin-backend(含 http/ 接口用例可直接联调),欢迎交流。
本文实现细节均可在仓库中对应验证:隔离在 src/modules/saas/scope.util.ts 与各 service 的 requireXxx,挂件在 src/modules/saas/widget/,SEO 模板在 src/modules/saas/public/help-template.util.ts。