第 19 章 · 实战一:生产级智能客服 Agent
本章目标:综合运用前 18 章所学,从零构建一个电商售后客服 Agent——意图识别分流、订单查询、退换货政策 RAG 问答、人工转接与会话记忆一个不少;并把成本分级路由、密钥安全、评估闭环三条生产红线在构建过程中落地,而不是事后补课。
19.1 需求分析与架构设计
先明确业务边界。这个客服 Agent 只做售后场景的四件事:
| 需求 | 对应能力 | 用到章节 |
|---|---|---|
| 闲聊/简单问题快速应答 | 廉价小模型分流 | ch08 branch |
| 查订单状态 | queryOrder 工具 | ch05 createTool |
| 退换货政策问答 | RAG 检索政策文档 | ch12 |
| 情绪激动/超权限诉求 | workflow suspend 转人工 | ch09 |
整体架构如下——意图路由是成本与质量的总闸门:
用户消息
│
▼
┌──────────────────┐ 简单/闲聊(~70% 流量)
│ 意图路由 triage │──────────────▶ gpt-5-mini 直接回复
│ (gpt-5-mini) │
└───────┬──────────┘
│ 业务问题(查单/退换货)
▼
┌─────────────────────────────────┐
│ expertAgent(claude-sonnet) │
│ ├─ queryOrder 工具 ──▶ 订单 DB │
│ ├─ RAG 政策检索 ──▶ 向量库 │
│ └─ Memory(按用户隔离会话) │
└───────┬─────────────────────────┘
│ 情绪激动 / 超出退款权限
▼
workflow.suspend() ──▶ 人工客服接管设计决策只有一条主线:让便宜的模型处理能应付的问题,贵的模型只花在刀刃上。
19.2 项目骨架与模型分级路由
复用第 1 章脚手架,新增目录结构:
npm install @mastra/core @mastra/libsql @mastra/memory zod
mkdir -p src/mastra/{agents,tools,workflows}先写两个档位的 Agent。注意 instructions 遵循第 4 章的三原则(定角色、划边界、给格式):
// src/mastra/agents/triage-agent.ts —— 一线分诊:便宜模型扛住大多数流量
import { Agent } from '@mastra/core/agent';
export const triageAgent = new Agent({
id: 'triage',
name: 'Triage',
instructions: `
你是电商售后的第一道接待。职责:
- 判断用户意图:闲聊问候 / 订单查询 / 退换货咨询 / 投诉
- 闲聊与简单常见问题直接一句话回答
- 涉及具体订单或政策的请求,输出「转接」并附上用户的原始诉求摘要
始终使用中文,回复不超过 80 字。`,
// 低成本档:意图识别不需要旗舰模型
model: 'openai/gpt-5-mini',
});// src/mastra/agents/expert-agent.ts —— 专家坐席:只在必要时消耗高能力模型
import { Agent } from '@mastra/core/agent';
export const expertAgent = new Agent({
id: 'expert',
name: 'Support Expert',
instructions: `
你是资深售后专员,处理订单查询与退换货咨询。
规则:
- 回答政策问题时必须依据提供的政策资料,不得编造条款
- 查询订单前必须拿到合法订单号
- 用户情绪激动或要求超出标准政策时,回复「为您转接人工客服」
始终使用中文。`,
// 高能力档:工具调用 + 政策推理
model: 'anthropic/claude-sonnet-4-5',
});用 workflow 的 branch() 把两者串成分流主干(第 8 章语法):
// src/mastra/workflows/support-router.ts —— 意图分流主干
import { createStep, createWorkflow } from '@mastra/core/workflows';
import { z } from 'zod';
const classifyStep = createStep({
id: 'classify',
inputSchema: z.object({ message: z.string(), userId: z.string() }),
outputSchema: z.object({ simple: z.boolean(), message: z.string(), userId: z.string() }),
execute: async ({ inputData, mastra }) => {
// 让便宜的小模型判断是否为简单问题
const res = await mastra.getAgent('triage').generate(inputData.message);
const simple = !res.text.includes('转接');
return { simple, message: inputData.message, userId: inputData.userId };
},
});
export const supportFlow = createWorkflow({
id: 'support-router',
inputSchema: z.object({ message: z.string(), userId: z.string() }),
outputSchema: z.object({ answer: z.string() }),
})
.then(classifyStep)
.branch([
// 简单问题:直接采纳 triage 的回答(已在 classify 中缓存)
[async ({ inputData }) => inputData.simple, answerSimpleStep],
// 业务问题:交给专家坐席走工具 + RAG 全链路
[async () => true, expertAnswerStep],
])
.commit();成本收益
典型电商客服流量中闲聊与常见问题占比约 70%。全量跑旗舰模型的账单,在分流后通常能降到原来的三分之一以下。
19.3 订单查询工具与政策 RAG
专家坐席需要两件武器:查订单的工具和检索政策的能力。
// src/mastra/tools/query-order.ts —— 订单查询工具:连模拟数据库
import { createTool } from '@mastra/core/tools';
import { z } from 'zod';
// 演示用模拟数据;生产环境替换为真实订单服务 API
const mockOrderDb: Record<string, { status: string; amount: number; item: string }> = {
'SO-2026-0001': { status: '已发货', amount: 299, item: '无线耳机' },
'SO-2026-0002': { status: '待付款', amount: 1299, item: '机械键盘' },
};
export const queryOrderTool = createTool({
id: 'query-order',
description: '根据订单号查询订单状态、金额与商品,仅在用户提供订单号时调用',
inputSchema: z.object({
orderId: z.string().regex(/^SO-\d{4}-\d{4}$/, '订单号格式如 SO-2026-0001'),
}),
outputSchema: z.object({
found: z.boolean(),
status: z.string().optional(),
amount: z.number().optional(),
item: z.string().optional(),
}),
execute: async ({ inputData }) => {
// 最小权限原则:这里只读,绝不给写库凭据
const order = mockOrderDb[inputData.orderId];
if (!order) return { found: false };
return { found: true, ...order };
},
});再把退换货政策做成可检索的 RAG 片段(简化版实现,完整向量库见第 12 章):
// src/mastra/tools/policy-rag.ts —— 退换货政策检索:关键词匹配演示版
import { createTool } from '@mastra/core/tools';
import { z } from 'zod';
// 生产环境替换为向量库语义检索(pgvector / LibSQL vector)
const policyDocs = [
{ topic: '七天无理由', text: '签收后 7 天内不影响二次销售可无理由退货,运费买家承担。' },
{ topic: '质量问题', text: '质量问题退货免运费,需提供照片凭证,审核 24 小时内完成。' },
{ topic: '退款时效', text: '退货签收后 3 个工作日内原路退回,具体以支付平台为准。' },
];
export const policyRagTool = createTool({
id: 'search-policy',
description: '检索退换货政策条文,回答政策相关问题前必须先调用本工具',
inputSchema: z.object({ keyword: z.string().describe('政策主题关键词,如"退款"') }),
outputSchema: z.object({ clauses: z.array(z.string()) }),
execute: async ({ inputData }) => ({
clauses: policyDocs
.filter((d) => d.text.includes(inputData.keyword) || d.topic.includes(inputData.keyword))
.map((d) => d.text),
}),
});把两个工具挂到专家坐席上:
// src/mastra/index.ts —— 注册中心组装
import { Mastra } from '@mastra/core';
import { expertAgent } from './agents/expert-agent';
import { triageAgent } from './agents/triage-agent';
// 工具通过构造参数挂载,Agent 自主决定何时调用
expertAgent.__registerTools?.({ queryOrderTool, policyRagTool });
export const mastra = new Mastra({
agents: { triageAgent, expertAgent },
workflows: { supportFlow },
});19.4 会话记忆与人工转接
客服最怕"每次都要重新解释一遍"。用 Memory 按 resource/thread 隔离每个用户的会话(第 10 章):
// generate 时传入 memory 选项,同一 userId 自动延续上下文
await expertAgent.generate('我的耳机左声道没声音了', {
memory: {
resource: userId, // 用户身份:跨会话保留长期信息
thread: `after-sale-${orderId ?? 'general'}`, // 一个工单一个线程
},
});
// 用户下一轮问"刚才说的怎么申请?"时,Agent 能理解"刚才"指什么转人工是客服 Agent 的安全阀。用 workflow 的 suspend 实现可控中断(第 9 章):
// src/mastra/workflows/handoff-step.ts —— 人工转接步骤
import { createStep } from '@mastra/core/workflows';
import { z } from 'zod';
export const handoffStep = createStep({
id: 'handoff',
inputSchema: z.object({ message: z.string(), userId: z.string() }),
outputSchema: z.object({ escalated: z.boolean(), ticketId: z.string().optional() }),
execute: async ({ inputData, suspend }) => {
// 触发条件:负面情绪词 或 用户明确要求人工
const angry = /(投诉|315|工商|垃圾|骗子|人工)/.test(inputData.message);
if (angry) {
// 挂起并携带上下文,人工坐席在工作台看到后 resume 继续
await suspend({
reason: '情绪升级或用户要求人工',
transcript: inputData.message,
userId: inputData.userId,
});
}
return { escalated: false };
},
});判断逻辑放代码而不是提示词
"什么时候转人工"是业务规则,写进代码正则/阈值可以测试和审计;只靠提示词约束的转人工在压测下必然漏判。
19.5 上线前的安全与评估
密钥:零硬编码
所有 Provider 密钥只从环境变量读取——Mastra 的模型路由会自动读取 OPENAI_API_KEY / ANTHROPIC_API_KEY:
# .env(已加入 .gitignore,绝不提交)
OPENAI_API_KEY=sk-...
ANTHROPIC_API_KEY=sk-ant-...
LIBSQL_URL=file:memory.db// ❌ 错误示范:任何形式的硬编码密钥迟早随仓库泄漏
// model: { apiKey: 'sk-abc123' }
// ✅ 正确:声明 provider/model 字符串即可,密钥由运行时环境注入
model: 'openai/gpt-5-mini'再补一道 PII 脱敏保险——用户消息进 prompt 前打码手机号:
// src/mastra/processors/mask-pii.ts —— 输入脱敏 Processor
export const maskPii = {
name: 'maskPii',
processInput: ({ messages }) =>
messages.map((m) => ({
...m,
content:
typeof m.content === 'string'
? // 中国大陆手机号脱敏:保留前三后四
m.content.replace(/(1[3-9]\d)\d{4}(\d{4})/g, '$1****$2')
: m.content,
})),
};质量:给客服配一个"阅卷老师"
上真实用户之前,先用 Scorer 对固定样本集跑准确率评估(第 15 章):
// src/mastra/scorers/policy-accuracy.ts —— 用廉价模型当阅卷老师
import { Faithfulness } from '@mastra/evals/nlp';
// 评估集:政策问答的标准答案对(来自真实客服记录)
const cases = [
{ query: '七天无理由谁出运费?', expectContains: '买家承担' },
{ query: '质量问题退货运费呢?', expectContains: '免运费' },
];
for (const c of cases) {
const res = await mastra.getAgent('expert').generate(c.query);
const pass = res.text.includes(c.expectContains);
console.log(pass ? '✅' : '❌', c.query);
}
// Faithfulness scorer 衡量回答是否忠实于检索到的政策原文
const score = await Faithfulness.score({ input: cases[0].query });把这段脚本挂进 CI:每次改 instructions 或换模型都必须重跑,通过率低于阈值直接阻断合并——这就是第 15 章说的评估闭环在本项目里的落点。
跑通一次完整对话
npx mastra dev # 启动开发服务器,Studio 打开 http://localhost:4111在 Studio 的 Playground 里依次发送:「在吗」→「帮我查下订单 SO-2026-0001」→「耳机有质量问题怎么退货?」→「你们就是骗子!」,验证四条链路分别命中:小模型直答 → 工具查单 → 政策 RAG → suspend 转人工。
19.6 生产检查单
上线前逐项打勾,别靠运气:
- [ ] 密钥全部来自环境变量/密管系统,
git log -p扫描无泄漏历史 - [ ] 模型分级路由生效:统计确认多数流量走小模型
- [ ] PII 脱敏 Processor 已启用,日志抽样复核无手机号明文
- [ ] 工具最小权限:查询工具无写库凭据
- [ ] CI 评估套件接入,通过率阈值已配置
- [ ] suspend 转人工链路有人工坐席真的在接收端值守
- [ ] Storage 外置且有备份策略(LibSQL 文件库仅限本地开发)
- [ ] 关键指标有观测:P95 延迟、错误率、转人工率
本章小结
- 架构主线一条:意图分流是小模型与大模型之间的总闸门;
- 工具给行动力(查订单),RAG 给知识(政策问答),Memory 给连续性(按 resource/thread 隔离);
- 转人工是安全阀:触发条件写在代码里而非提示词里;
- 密钥零硬编码、PII 脱敏、CI 评估闭环——三条红线都在构建过程中落地。
🧪 随堂测验
点击你认为正确的选项。答错时会展示正确答案与原因解析。
1. 客服 Agent 采用模型分级路由的主要目的是?
2. "什么时候转人工"的逻辑应该写在哪里?
3. Memory 中 resource 与 thread 的正确搭配是?
4. 关于 API Key 的做法,正确的是?
🛠️ 动手实践
- 为
queryOrderTool增加cancelOrder写操作工具,但故意不给它配置生产数据库凭据,体会"最小权限"如何在代码结构层面强制实现。 - 在 Studio 中构造一条"我要投诉到 315"的消息,观察 suspend 是否触发,并手动 resume 该 workflow 完成人工处理闭环。
- 向评估集中补充 5 条你所在业务的真实客服问题,运行评估脚本,记录通过率并分析失败用例的共性原因。