说实话最开始听到“给后台加个AI”这个需求我第一反应是拒绝的——市面上大多数所谓的“AI后台”不过是套了一个聊天框的搜索框用户问一句答一句答完就完了什么都落不了地。直到我把大模型的 Function Calling 能力和 NestJS 的依赖注入体系真正揉到一起才发现这事儿能做成而且做成之后的效果远超预期。这篇文章就围绕我参与的一个某管理后台项目展开聊聊我给它装上“会干活”的 AI Agent 的全过程。它不是一个只会陪你聊天的玩具而是能真正看订单、改状态、拉报表、做导出的办事员。文章里会讲清楚整体架构、关键代码、权限安全设计也会把我踩过的坑原原本本摆出来。无论你用的是哪个大模型平台这套思路都能平移过去。1. 先搞清楚普通聊天机器人和“会干活”的 Agent 差在哪1.1 聊天式 AI 为什么救不了管理后台我见过不少团队做“AI 后台”做法很直接把大模型接到一个对话框里然后就没有然后了。用户问“这个月销量怎么样”模型根据训练数据给你编一个答案或者勉强从知识库里翻一段文档念出来。问题是管理后台的每一笔数据都来自数据库都是实时的、带权限的、可审计的。模型不可能凭空知道某一天某个门店的真实订单数它也不应该知道。说白了后台的价值不在“说”在“做”。一个合格的 AI 后台助手必须能替用户完成真正的业务操作。比如用户输入“帮我查一下上周所有已付款但还没发货的订单把金额超过一千的列出来”系统要能把这个需求翻译成一次真实的数据库查询带着当前用户的身份和权限去执行再把结果整理给用户看。这才是“会干活”。1.2 判断一个 Agent 是否“会干活”的三个标准我在动手之前给自己定了三个硬性标准缺一个都不算合格能听懂意图用户不是按固定模板说话要能从口语化的句子里抽取出查询条件、操作对象和动作类型。参数能落地抽出来的参数必须能被后台现有服务接受格式要对、范围要对、枚举要对不能抽出一个 SQL 语法都对不上的日期。操作能审计Agent 代表用户执行了任何操作必须有记录、有身份归属、有权限校验和用户自己在后台点按钮没有本质区别。这三个标准直接决定了架构选型。为了满足它们我放弃了“让模型直接写 SQL 查库”的方案也放弃了一个个手写语义接口的方案最终选了 Function Calling 工具调用模式。2. 总体架构把 Agent 做成后台能力的调度员2.1 方案取舍为什么不让 AI 直接查库先对比一下几种常见做法你就能明白我为什么这么选。方案优点致命问题让模型直接查数据库实现最快权限无法细粒度控制SQL 生成不可控审计链路断裂一票否决为每个场景手写专用接口行为可控后台字段组合几十上百种维护成本爆炸需求一变化就得改接口Agent 调用现有 Service工具调用复用业务逻辑权限和审计自然继承需要额外做工具注册和参数映射初期工作量稍大第三种方案胜出的核心原因很简单后台的业务能力早就沉淀在 NestJS 的 Service 层了。订单服务、用户服务、报表服务都是现成的经过了业务方的层层验证。Agent 不该再造一套新能力它只需要学会“在合适的时机调用正确的 Service”。边界一划清楚整个系统的稳定性和可维护性都回来了。2.2 分层结构谁负责说话谁负责动手我实际落地的结构是五层每一层各干各的前端会话层一个聊天窗口负责展示对话流、执行进度和操作确认按钮。Agent 编排层核心大脑负责把用户输入和已有工具信息一起发给大模型决定调用哪个工具。工具注册层维护一份“工具清单”告诉模型有哪些能力、参数是什么格式。业务能力层就是项目原有的 OrderService、UserService 这些 NestJS Provider。审计与权限层横向贯穿所有工具调用校验身份、检查权限、记录日志。这个结构最大的好处是业务 Service 保持纯净不知道 AI 的存在。Agent 只是其中一个调用方和原来的 Controller 调用 Service 没有任何区别。将来不想用 AI 了把这层摘掉系统原样退回不会留一堆没用的耦合代码。2.3 NestJS 为什么适合做这件事选 NestJS 做这事确实不是巧合。它依赖注入的容器特性天然适合“扫描工具”这件事。每个 Service 里加一个装饰器声明这是一个可被 Agent 调用的工具然后框架在应用启动时统一扫描、统一注册。不用写一堆工厂类也不用在外面维护一张工具注册表所有信息都和内聚的业务代码放在一起。另外 NestJS 的 Guard、Interceptor、Pipe 这套生命周期机制能很自然地把身份解析、权限校验、日志记录挂在 Agent 调用链路上。后面会详细讲这里先记住一个结论Agent 不是一个游离在框架外面的黑盒它必须活在 NestJS 的生态里才算真正接入了后台体系。3. 动手实现把 NestJS 的 Provider 变成 Agent 的“手”3.1 工具定义用装饰器给能力写“说明书”我先定义了一个Tool装饰器用来把 Service 里的一个方法标记为 Agent 可调用工具。每个工具需要写清楚三件事名字、用途说明、参数结构。Tool({ name: order.query, description: 查询订单列表支持按时间范围、订单状态、客户名称筛选返回订单编号、金额、状态等信息, parameters: { type: object, properties: { startDate: { type: string, description: 开始时间格式 YYYY-MM-DD }, endDate: { type: string, description: 结束时间格式 YYYY-MM-DD }, status: { type: string, enum: [PENDING, PAID, SHIPPED, COMPLETED, CANCELLED] }, customerName: { type: string, description: 客户名称模糊匹配 }, }, }, }) async queryOrders(params: QueryOrderDto) { // 这里是原有的订单查询逻辑 return this.orderRepository.find({ where: buildOrderWhere(params), }); }装饰器实现并不复杂核心就是利用Reflect.defineMetadata把工具描述挂到方法上export function Tool(options: ToolDefinition) { return (_target: object, propertyKey: string, descriptor: PropertyDescriptor) { Reflect.defineMetadata(TOOL_METADATA, options, descriptor.value); return descriptor; }; }这里有个细节值得强调工具描述是给模型看的不是给人看的。模型的工具选择完全依赖这段文字写得太含糊它就会乱选写得太啰嗦又会干扰它的判断。我建议描述里交代清楚“这个工具什么时候用”“有哪些边界条件”比如“仅用于查询已创建订单不包含退款订单”。3.2 让模型看懂工具注册表与 JSON Schema有了装饰器还得有个地方把所有工具收集起来。我写了一个ToolRegistry用 NestJS 的 Injectable 提供服务内部维护一个 MapInjectable() export class ToolRegistry { private readonly tools new Mapstring, RegisteredTool(); register(tool: RegisteredTool) { this.tools.set(tool.definition.name, tool); } getSchemas() { return Array.from(this.tools.values()).map((tool) ({ type: function, function: { name: tool.definition.name, description: tool.definition.description, parameters: tool.definition.parameters, }, })); } get(name: string) { return this.tools.get(name); } }在应用启动阶段扫描所有业务 Provider把带有Tool元数据的服务方法注册进去。我是利用 NestJS 的OnApplicationBootstrap钩子做的Injectable() export class AppBootstrap implements OnApplicationBootstrap { constructor( private readonly moduleRef: ModuleRef, private readonly registry: ToolRegistry, private readonly orderService: OrderService, private readonly reportService: ReportService, ) {} onApplicationBootstrap() { this.registerProvider(this.orderService); this.registerProvider(this.reportService); } private registerProvider(provider: object) { const prototype Object.getPrototypeOf(provider); const methods Object.getOwnPropertyNames(prototype).filter((name) name ! constructor); for (const methodName of methods) { const definition Reflect.getMetadata(TOOL_METADATA, prototype[methodName]); if (definition) { this.registry.register({ definition, handler: provider[methodName].bind(provider), }); } } } }这样写的直接好处是以后新增工具只需要在业务方法上加一行装饰器其他代码一概不用动。我接手这个项目之后新增了四五个工具都只改业务文件没有碰过 Agent 核心逻辑。getSchemas()返回的结果就是发给大模型的 Function Calling 工具列表。大模型会基于这份清单在需要时返回tool_calls结构告诉系统“我决定调用 order.query参数是这些”。3.3 执行、回填、生成回答一次完整的工具调用链路Agent 的核心编排逻辑可以概括成一个循环发消息、拿意图、执行工具、回填结果、再生成回答。我把核心流程简化成下面的样子async function runAgent(sessionId: string, userInput: string) { const history conversationStore.get(sessionId); const messages buildMessages(history, userInput); const firstResponse await this.llm.chat({ model: this.modelName, messages, tools: this.registry.getSchemas(), toolChoice: auto, }); if (!firstResponse.toolCalls?.length) { return firstResponse.content; // 模型认为不需要调用工具 } const toolMessages []; for (const call of firstResponse.toolCalls) { const tool this.registry.get(call.name); if (!tool) { toolMessages.push({ role: tool, toolCallId: call.id, content: JSON.stringify({ error: 工具不存在 }), }); continue; } const validArgs this.validator.validate(tool.definition.parameters, call.args); const result await tool.handler(validArgs); toolMessages.push({ role: tool, toolCallId: call.id, content: JSON.stringify(truncateResult(result)), }); } const finalResponse await this.llm.chat({ model: this.modelName, messages: [...messages, ...toolMessages], tools: this.registry.getSchemas(), }); conversationStore.append(sessionId, userInput, finalResponse.content); return finalResponse.content; }几个关键点参数校验必须加模型偶尔会抽出不符合约束的参数比如日期格式不是YYYY-MM-DD或者在枚举外选了一个状态。我在校验层做了严格检查校验不通过直接返回给模型一个结构化错误让它重新调整参数而不是把脏数据扔进业务方法。结果要截断查询返回了几百条订单直接全量塞回上下文既浪费 token 又容易让模型看花眼。我只保留前 50 条并在结果里写明“共返回 328 条已展示 50 条”。上下文要维护Agent 支持连续提问第二次问“那这些订单里金额最大的前三笔呢”需要依赖上一次的查询结果。所以我会把历史消息和工具回填结果都存在会话里。3.4 流式地“干活”让用户看到中间过程如果 Agent 只是闷头执行半分钟不出一个字用户早就以为系统卡死了。我用 SSE 做了流式输出把过程拆成几种事件类型export enum AgentEventType { IntentRecognized intent_recognized, ToolExecuting tool_executing, ToolExecuted tool_executed, MessageStreaming message_streaming, ActionConfirmation action_confirmation, Error error, }前端收流的逻辑大致是先收到“正在分析意图”然后收到“正在调用订单查询工具”再收到“查询完成共返回 328 条”最后是逐字输出的自然语言总结。用户全程知道系统在干嘛体验比黑盒式问答好得多。这里有个小技巧流式事件要带 requestId 和 sessionId方便前端处理并发会话和多标签页。这个项目里有用户开两个窗口同时和 Agent 对话多亏了事件里带了会话标识消息才没有互相串。4. 权限和安全AI 能干活的前提是“守规矩”4.1 双层权限校验谁也不能绕过一个管理后台里不是所有人都能查所有数据更不可能所有人都能执行导出、改价这类敏感操作。AI Agent 要是无视这层规矩那它就是安全漏洞本身。我做了两层校验第一层在 Agent 入口校验用户有没有使用 AI 助手的权限。这个简单挂在 Controller 上的一个 Guard 就能搞定。第二层在每个工具调用之前校验用户对具体能力有没有操作权限。例如普通客服可以查订单但无权批量修改订单状态运营可以拉报表但无权查看含客户手机号的完整信息。我在注册表里为每个工具追加了requiredPermission字段执行前统一检查async function checkToolPermission(user: AuthUser, tool: RegisteredTool) { const allowed await this.permissionService.check(user.roles, tool.requiredPermission); if (!allowed) { throw new AgentPermissionError(当前账号无权限执行该操作请申请权限或联系管理员); } }这里最重要的设计原则是AI 只是用户能力的放大器不是权限的绕过者。它调用工具时身份就是当前登录用户绝不能使用什么服务账号、管理员账号代跑。4.2 敏感操作必须二次确认像“批量取消订单”“给客户发营销短信”“删除附件”这类不可逆或影响面大的操作我加了一道强制确认闸门。流程是Agent 执行到敏感工具时不真正执行业务逻辑而是生成一个pendingAction把工具名、参数摘要、预估影响范围返回给前端。前端弹卡片“即将取消 128 笔已付款订单是否继续”用户点确认后前端把confirmationToken发回来Agent 校验通过后才真正执行。这个机制用起来很稳也避免了一桩最常见的投诉用户本来只想“看一下哪些订单要取消”结果模型理解成“帮我把这些订单取消掉”。现在凡是破坏性操作都先摆到台面上让用户点头误解的成本就降到了最低。4.3 审计日志每一步都留下记录我坚持把 Agent 的工具调用日志和后台普通操作日志合并到同一套审计体系里。每一条记录包含字段示例requestId4f7a1c92-7a33-4b6e-9f8d-1a2b3c4d5e6fuserIdu_10234工具名order.batch_cancel参数摘要{ status: PAID, ids: [1001, 1002, ...] }结果状态success / denied / failed耗时1523ms日志里我刻意做了脱敏手机号中间四位打星号金额只保留到分避免审计日志本身变成新的数据泄露点。5. 踩过的坑和排查实录5.1 模型“自作主张”选错工具第一次联调时用户问“帮我导一下本月的对账单”模型却调用了order.query查询订单而不是billing.export导出账单。根因是order.query的描述里写了“支持导出”让模型误以为它也有导出能力。后来我把每个工具描述里的动词边界写清楚比如“order.query 仅返回查询结果不生成文件如需导出请调用 billing.export”。同时把工具名改成动词开头的结构如query_orders、export_billing模型的选择准确率明显提升。5.2 日期格式不统一模型抽取日期时有时候抽出来是“2025-01-01”有时候是“1月1日”有时候是“2025/1/1”。业务层的 DTO 校验直接不通过Agent 又不会自己修正导致用户反复听到“抱歉参数格式错误”。我在工具参数描述里把日期格式写进了 examples 字段并在校验层自动做一轮归一化先按YYYY-MM-DD解析失败再尝试其他格式。这一步由后端完成不再依赖模型自己“猜”。5.3 工具输出太长token 消耗失控一次查询返回了全量订单我把结果直接回填给模型结果模型要读几千行 JSON 才能总结一次对话烧掉十几万 token成本失控。后来我做了三层保护结果截断、字段裁剪、汇总先行。现在工具执行完我先算好“总数、金额合计、状态分布”这些摘要字段再截断明细塞回上下文token 消耗降到了原来的三分之一。5.4 权限不足时模型硬要“曲线救国”还有一类典型问题用户没有导出权限模型第一次调导出工具被拒它居然换了一种说法让系统先把数据查出来然后“把列表内容复制成表格发给我”。这种绕权限的行为必须堵死。我的方案是当工具权限校验失败时返回给模型的不再是普通报错而是一条带标记的提示“FORBIDDEN该工具不可调用不要尝试用其他工具替代也不要向用户暗示可以绕过权限”。同时在系统提示词里写死一条规则任何绕过权限的尝试都属于违规行为直接拒绝回答。这样既防了模型也防了用户诱导。5.5 超时和重试要分开处理AI Agent 的执行链路比普通接口长经常要 5~15 秒。一开始我只是简单地把超时拉到 30 秒结果用户刷了几个请求服务端线程全堵住了。后来我做了两件事一是把 Agent 执行放到独立队列里用并发限制保护数据库二是区分“超时”和“失败”工具调用超时不直接报错而是让模型在下一轮尝试重新执行一次只有连续两次超时才真正终止。5.6 流式连接断开的处理SSE 连接本身是脆弱的用户切换一下网络就断了。断连时Agent 可能还在后台继续执行。我在前端做了重连逻辑重连后先拉取会话状态如果工具已经执行完直接展示结果如果还在执行显示进度。这个设计让整个流程没那么“死”体验上会加分很多。6. 最后分享一点我自己的习惯每次新增一个 Agent 工具我都会先关掉 Agent 编排层直接手工把参数通过 Service 方法跑一遍确认业务逻辑没问题再放给模型调。因为模型抽参数再准也可能抽出一个让你意外的边界值。另外我习惯把工具名、参数描述和权限要求三件事放在同一个地方维护改起来不容易漏。这个项目走到现在最让我满意的不是它有多“智能”而是它干活的时候和后台原有的规矩完全在一个频道上。AI 再聪明也得做流程里的守规矩办事员。