别让 AI 只会聊天:我给 NestJS 管理后台装上了"会干活"的 AI Agent
全网唯一指定套路开头:项目开源、代码在 GitHub, Nuggets 的朋友们可以直接拉下来跑。
前言
市面上 90% 的"管理后台 + AI"是这样的:右上角一个聊天窗,问它"帮我写个周报",它哗哗输出一堆正确的废话。
但真正的诉求是什么?是我坐在后台里说一句——
"帮我查一下现在有多少用户" "添加一个员工,叫张三" "把用户查询权限给 common 角色"
然后它真的去查库、真的创建了用户、真的改了权限。
这就不是"套壳 ChatGPT"能解决的了,需要:模型配置化(随时换厂商)+ Function Calling 工具调用(意图识别 → 调真实接口 → 结果回喂 → 流式回答)+ 权限门控(AI 干活也得有权限)。
这篇文章记录我在一个 NestJS 11 + Vue3 权限中枢项目里把这套东西完整落地的过程,包括三个印象深刻的坑。先看效果:
![AI 对话调用真实工具转存失败,建议直接上传图片文件]
(上面那句"帮我查一下人员名单",AI 自己决定调用
search_users 工具查了真实数据库,把结果渲染成表格;工具调用轨迹以芯片形式展示在气泡上方)
项目是什么
一句话:NestJS 细粒度权限中枢(接口/按钮/数据行三级 RBAC)+ AI 基础设施 + Vue3 管理端。
- 后端:NestJS 11 · Prisma 6 · PostgreSQL · Redis · JWT 双 Token · argon2
- 前端:Vue3 · Vite · Element Plus · Tailwind CSS 4 · Pinia(基于 art-design-pro 精简改造)
- AI:OpenAI 兼容协议抽象层,一套代码切 OpenAI / DeepSeek / 通义 / 智谱 / Kimi / 小米 MiMo / Ollama
权限体系这里不展开(接口 @RequirePermissions 装饰器、前端 v-auth 指令、数据行 @DataScope,前后端同一套权限码,改权限不用重新登录),重点讲 AI 这条线。
一、模型配置化:别把厂商写死在 env 里
最初的版本很"标准":.env 里配 AI_PROVIDER=deepseek,工厂函数按它装配 Provider。问题是——换厂商要改配置文件重启,运营/交付的同学根本不会碰终端。
参考了 CC Switch(一个管理 AI 编程工具供应商配置的桌面应用)的思路,我把配置搬进了数据库:
model AiProvider {
id String @id @default(uuid())
name String
protocol String // OPENAI_COMPAT / OLLAMA(上游协议格式)
baseUrl String @map("base_url")
apiKey String? @map("api_key")
enabled Boolean @default(true)
// ...
models AiModel[]
}
model AiModel {
id String @id @default(uuid())
providerId String @map("provider_id")
name String // 模型 ID,如 gpt-4o-mini
maxOutput Int? @map("max_output") // 最大输出 tokens
contextWindow Int? @map("context_window") // 上下文窗口
isDefault Boolean @default(false) @map("is_default")
// ...
}
然后是一个注册表代替原来的工厂单例:
@Injectable()
export class AiProviderRegistry {
private readonly cache = new Map<string, CachedProvider>();
/** 按 modelId → 模型名 → 全局默认模型 三级查找,未命中返回 null(回落 env) */
async resolve(modelId?: string, modelName?: string): Promise<ResolvedRoute | null> {
const model = await this.findModel(modelId, modelName);
if (!model) return null;
return {
provider: this.getProviderInstance(model.provider),
model: model.name,
maxTokens: model.maxOutput ?? undefined, // 模型级 max_output 覆盖全局
};
}
}
几个设计决策:
- 实例缓存按
provider.id + updatedAt:配置一改(updatedAt变了)自动重建 OpenAI Client,Controller 里调一下invalidate()也行,反正查询很便宜; apiKey掩码返回:列表接口只回sk-ab***xyz,更新时"不传 = 保留原值",连掩码原样回传都被后端识别为"没改";- env 不删,降级为引导:
seed把.env的配置导入为首条供应商记录(固定 ID 幂等),数据库有配置就走数据库,没有就走 env——老用户零迁移成本; - 前端做成 CC Switch 那种供应商卡片:协议格式(OpenAI 兼容 / Ollama)可选、模型可配最大输出和上下文窗口、还能一键从端点拉模型列表(OpenAI 兼容打
/models,Ollama 打/api/tags)。

二、Function Calling:让 AI 操作真实系统(本文重点)
2.1 协议设计
SSE 帧格式在原来的 {delta, done, usage, model} 基础上,加了一种工具活动帧:
data: {"delta":"我帮你查一下","done":false} ← 模型可以边说边干
data: {"tool":{"name":"search_users","status":"running"}}
data: {"tool":{"name":"search_users","status":"done"}}
data: {"delta":"当前系统共有 3 名用户……","done":false} ← 基于工具结果回答
data: {"done":true,"usage":{...},"model":"mimo-v2.6-flash"}
前端拿到 tool 帧就在气泡上方画一个状态芯片(⏳ → ✅),用户能看见 AI "正在调用工具",体验上是加分的。
2.2 Agent 循环
核心循环在 AiChatService,骨架如下(去掉细节后大约 40 行):
async *chatStream(userId, auth, dto, signal): AsyncIterable<ChatDelta> {
const route = await this.registry.resolve(dto.modelId, dto.model); // 数据库路由
const ctx = await this.tools.buildContext(auth); // 用户权限码集合
const conv: ChatMessage[] = [
{ role: 'system', content: this.buildSystemPrompt(auth, ctx) },
...(dto.history ?? []).slice(-20), // 前端传最近 20 条,多轮意图理解
{ role: 'user', content: dto.message },
];
for (let turn = 0; turn <= MAX_TOOL_TURNS; turn++) {
let pendingToolCalls: ToolCall[] | undefined;
let finalUsage = null, finalModel: string | undefined;
for await (const delta of this.ai.chatStream(userId, {
messages: conv, model, tools: this.tools.getTools(), signal, ...
}, route?.provider)) {
if (delta.done) {
if (delta.toolCalls?.length) pendingToolCalls = delta.toolCalls; // 模型要调工具
else { finalUsage = delta.usage; finalModel = delta.model; } // 最终答案
} else if (delta.delta) yield delta; // 文本流式透传
}
if (!pendingToolCalls?.length) {
yield { delta: '', done: true, usage: finalUsage, model: finalModel ?? model };
return;
}
// 逐个执行工具,SSE 下发活动帧
conv.push({ role: 'assistant', content: assistantText || null,
tool_calls: pendingToolCalls.map(t => ({ id: t.id, type: 'function',
function: { name: t.name, arguments: t.arguments } })) });
for (const tc of pendingToolCalls) {
yield { delta: '', done: false, tool: { name: tc.name, status: 'running' } };
const result = await this.tools.execute(tc, ctx);
yield { delta: '', done: false, tool: { name: tc.name, status: result.ok ? 'done' : 'error' } };
conv.push({ role: 'tool', content: JSON.stringify(result.data), tool_call_id: tc.id });
}
}
}
几个关键点:
- 流式聚合 tool_calls:OpenAI 流式协议里
delta.tool_calls是分片下的(index+ 增量arguments),我在 Provider 层按 index 拼接,流结束时统一发一个带聚合结果的结束帧。这样普通回答是逐 token 流式,工具调用轮由上层决策; MAX_TOOL_TURNS = 6:防止模型陷入"调用→失败→再调用"的死循环,超限直接告知用户缩小问题;- 中断级联:前端断开 →
AbortController→ 透传到 LLM 请求,不浪费 token。
2.3 权限门控:AI 是"戴着用户手套"干活的
这是我认为最值得抄走的设计。工具不是裸奔的 REST 代理,每个工具声明自己需要的权限码,执行前用当前对话用户的权限集合做校验:
async execute(call: ToolCall, ctx: ToolContext): Promise<ToolExecResult> {
const spec = this.specs().find(s => s.def.function.name === call.name);
if (!spec) return { ok: false, data: { error: `未知工具: ${call.name}` } };
// 与 PermissionsGuard 同语义:超管旁路,其余必须命中权限码
if (spec.permission && !ctx.user.isSuper && !ctx.perms.has(spec.permission)) {
return { ok: false, data: { error: `权限不足,执行该操作需要 ${spec.permission} 权限` } };
}
// ... JSON 参数解析 + handler 执行,异常也归一成 {error} 回喂模型
}
注意这个返回设计:权限不足不是抛异常,而是把 {error} 作为工具结果回喂给模型。于是模型会自然地告诉用户"你需要 system:user:create 权限"——而不是前端弹一个 403 白屏。
目前内置 9 个工具:
| 工具 | 权限码 | 类型 |
|---|---|---|
get_system_overview 系统概览 | dashboard | 读 |
search_users 搜索用户 | system:user:list | 读 |
create_user 创建用户 | system:user:create | 写 |
assign_user_roles 分配角色 | system:user:assign-roles | 写 |
list_roles / get_dept_tree / list_permissions | 对应 list 权限 | 读 |
set_role_permissions 配置角色权限 | system:role:assign-perms | 写 |
get_ai_usage AI 用量 | ai:usage:list | 读 |
写工具执行完还会发权限失效事件(PermCacheEvent.invalidateUser/invalidateRole),复用了权限体系"改权限不用重新登录"的基础设施——AI 改完权限,用户下一个请求就生效。
System Prompt 也按规范放在 src/infra/ai/prompts/assistant.v1.md,注入当前用户、权限码摘要、日期,规则里写了"写操作需先复述并确认""权限不足如实说""数据只来自工具结果"。加载器带缓存,nest-cli 配了 assets 把 md 拷进 dist。
2.4 前端:工具轨迹可视化
前端在气泡上方渲染工具芯片,running 时显示呼吸动画,完成变 ✅ / 失败变 ❌:
<div v-if="message.tools?.length" class="mb-1.5 flex flex-wrap gap-1.5">
<span v-for="(t, i) in message.tools" :key="i"
class="inline-flex items-center gap-1 rounded-full bg-g-100 px-2 py-0.5 text-xs">
<span>{{ aiToolLabel(t.name) }}</span>
<span v-if="t.status === 'running'" class="animate-pulse text-theme">···</span>
<ArtSvgIcon v-else-if="t.status === 'done'" icon="ri:check-line" class="text-success" />
<ArtSvgIcon v-else icon="ri:close-line" class="text-danger" />
</span>
</div>
扩展一个新工具 = 在 ai-tools.service.ts 加一个 spec 方法(JSON Schema + 权限码 + handler),前端标签表加一行中文,完事。
三、对话页:对着 Open WebUI 抄作业
功能层面没有黑科技,全是体力活,但有几个体验细节值得说:
- 会话侧栏:按"今天 / 昨天 / 近 7 天 / 更早"分组,支持搜索(标题+内容)、重命名、删除,
Ctrl+K聚焦搜索、Ctrl+Shift+O新建、Esc停止生成; - 打字指示:首 token 未到时显示三点跳动气泡,首字到了切换成流式光标——注意这两个状态互斥,别像我第一版那样同时渲染出"空光标框 + 打字框"两个框(见踩坑 3);
- 失败可重试:请求失败的消息标记
fail状态,显示"重试"按钮,点了自动移除失败回复重新发送; - 全局悬浮助手:除了独立对话页,还有一个 480px 的抽屉助手(起名"米答",原模板叫 Art Bot),任何页面都能唤起,走同一个后端 Agent。
对话历史目前存 localStorage(按用户隔离),服务端历史表的权限码 ai:chat:history 已经预留,这是下一期的事。
四、顺手把 mock 全扬了:工作台与通知
模板自带的工作台是"总访问次数 9120、新用户 +30%"这种假数据。既然 AI 都接上真实系统了,驾驶舱没理由是假的:
- 新增
/dashboard/stats、/dashboard/ai-trend($queryRaw按天聚合 + 补零)、/dashboard/recent-ai-calls三个接口; - 工作台 = 真实 KPI(用户数/权限点/今日 AI 调用/今日 Tokens)+ 14 天趋势图(调用次数/Tokens 切换)+ 最近调用列表;
- 用量报表用 ECharts 重做:KPI + 双柱趋势 + Token 占比环图 + 模型/厂商柱状 + 明细表;
- 模板的假用户表、社交动态、销售概览、外链"关于项目"——全部删除,删代码比写代码爽多了。
站内通知也补了后端:sys_notification 表按用户隔离,铃铛角标 = 真实未读数。有意思的是通知的生产方——AI 调用失败会自动给发起人发一条通知:
// infra 层只发事件,不 import 业务模块
if (entry.status === 'error' && entry.userId) {
this.eventEmitter.emit(AiEvent.callFailed, { userId, model, errorMsg });
}
// 通知模块消费
@OnEvent(AiEvent.callFailed)
async onAiCallFailed(payload: { userId: string; model: string; errorMsg: string }) {
await this.notify(payload.userId, 'NOTICE',
`AI 调用失败(${payload.model})`, payload.errorMsg.slice(0, 200), '/ai/chat');
}
五、踩坑记录(都是真金白银的时间)
坑 1:Windows 下 prisma generate 报 EPERM
nest start --watch 运行时,query engine 的 DLL 被进程占用,prisma migrate dev 在最后一步 generate 必然:
EPERM: operation not permitted, rename '...query_engine-windows.dll.node.tmp...' -> '...query_engine-windows.dll.node'
迁移 SQL 其实已经应用成功了,只是 Client 没重新生成。解法:停掉 dev 进程 → prisma generate → 再启动。以及 Prisma 引擎下载记得配国内镜像,不然卡死在 binaries.prisma.sh。
坑 2:Tailwind 语义色自带"自动暗色",别再叠 dark: 变体
art-design-pro 的灰阶是 CSS 变量(--color-g-100: var(--art-gray-100)),.dark 下整条灰阶反转:暗色模式下 g-800 = #c7c7d1(浅色!)、g-900 近白。
我给 AI 气泡写了 bg-g-200/70 dark:bg-g-800,结果暗色下 dark:bg-g-800 生效 = 浅灰底 + 近白字,用户反馈"看不清"。正确姿势:g-* 类永远不要配 dark: 变体,它自己会适配;要钉死颜色才用 dark:bg-[#18191a] 字面值。
坑 3:flex 子项的 min-height: auto,滚动容器被内容撑爆
对话页消息多了以后,用户反馈"看不到后面的内容,输入框也不见了"。用浏览器一量:滚动容器 clientHeight = 1262px,父容器只有 757px。
根因是 flex 子项默认 min-height: auto——flex-1 的消息列表被内容撑到比父容器还高,overflow-y-auto 根本不触发,底部工具栏被推出可视区。解法是在每一层 flex 容器到滚动容器之间加 min-h-0:
<div class="art-full-height"> <!-- 高度来源 -->
<div class="flex min-h-0 w-full flex-1">
<div class="flex min-h-0 flex-1 flex-col"> <!-- 右列 -->
<div class="min-h-0 flex-1 overflow-y-auto"> <!-- 消息滚动区 -->
顺带一提,.art-full-height 这个模板类自带 display:flex; flex-direction:column,想横向布局得在它里面再包一层。
六、验证
- 后端:ESLint / tsc strict / Jest 17 个用例 /
nest build全绿; - 前端:ESLint / Stylelint / vue-tsc 全绿;
- 真机链路:登录 → 对话页问"查人员名单" → 观察工具帧 → 返回真实用户表格;"添加员工张三" → 确认 →
create_user落库 → 用户管理页可见 → 分配角色 → 权限即时生效。
写在最后
这套东西最大的感悟:AI 功能的护城河不在提示词,而在"工具 + 权限 + 审计"这套工程底座。模型负责意图和话术,系统负责能力边界和数据真实性——权限不够就如实说,查不到就如实说,这才是企业级该有的样子。
下一步计划:服务端会话历史(权限码已预留)、流式输出工具参数(中间轮也逐字)、把模型成本单价表也搬进数据库。
仓库:nest-admin-backend(含完整 seed、http 用例、架构文档),觉得有帮助的话点个 Star ⭐
觉得文章不错,也请点个赞再走~ 有问题评论区见。