项目源码:
text
https://github.com/fishman132yangbo/customer-service-agent
先看这个项目最终能做什么。
启动 HTTP 服务后,调用接口:
bash
curl -X POST http://127.0.0.1:5188/api/chat \
-H 'Content-Type: application/json' \
-d '{"sessionId":"demo","message":"我昨天买的保温杯什么时候发货?"}'
它会返回一个客服式回复:
json
{
"reply": "您好,发货会按订单顺序处理。为了帮您进一步确认情况,麻烦提供一下订单号。",
"sessionId": "demo"
}
这个项目不是一个"把问题丢给大模型"的 demo。
它重点解决了客服 Agent 最基础、也最容易被忽略的三个问题:
- 怎么让模型知道品牌规则和商品信息
- 怎么让同一个用户的多轮对话有上下文
- 怎么同时支持 CLI 调试和 HTTP 接口调用
项目地址结构如下:
text
customer-service-agent/
src/
agent.js # 核心对话流程
model.js # 创建 LangChain 模型
memory.js # 本地 JSON 历史记录
knowledge.js # 本地知识库加载
server.js # HTTP API
cli.js # 命令行对话入口
config.js # 环境变量配置
data/
knowledge.json # 商品和客服规则
history.json # 会话历史
test/
agent.test.js
memory.test.js
下面按实现顺序拆开讲。
1. 技术选型:LangChain JS + 通义千问兼容 OpenAI 接口
项目依赖很少:
json
{
"dependencies": {
"@langchain/core": "^1.1.44",
"@langchain/openai": "^1.4.5",
"zod": "^4.4.3"
}
}
这里用了 @langchain/openai,但模型不是只能接 OpenAI。
通义千问的 DashScope 提供 OpenAI-compatible 接口,所以可以通过 baseURL 接进去:
js
import { ChatOpenAI } from "@langchain/openai";
export function createModel(config) {
return new ChatOpenAI({
apiKey: config.apiKey,
model: config.model,
temperature: 0.4,
timeout: config.timeoutMs,
streamUsage: false,
configuration: {
baseURL: config.baseUrl
}
});
}
对应 .env:
bash
AI_API_KEY=你的百炼 Key
AI_BASE_URL=https://dashscope.aliyuncs.com/compatible-mode/v1
AI_MODEL=qwen-plus
AI_TIMEOUT_MS=30000
PORT=5188
这里有两个小细节值得注意。
第一,temperature 设置成 0.4。
客服场景不适合太发散。用户问发货、退款、商品信息,回答应该稳定、克制,不应该每次都编出新说法。
第二,timeout 放到配置里。
调用模型是外部网络请求,超时要能调。不要把超时时间硬编码死。
2. 核心流程:chat 函数只做一件事
整个客服 Agent 的核心入口在 src/agent.js:
js
export async function chat({
sessionId = "default",
message,
model,
historyPath = defaultHistoryPath,
knowledgePath = defaultKnowledgePath
}) {
const userMessage = String(message || "").trim();
if (!userMessage) throw new Error("请输入用户消息");
if (!model?.invoke) throw new Error("缺少可用模型");
const history = await getMessages(historyPath, sessionId);
const knowledge = await loadKnowledge(knowledgePath);
const messages = buildMessages({ knowledge, history, userMessage });
const result = await model.invoke(messages);
const reply = extractContent(result);
await appendMessage(historyPath, sessionId, "user", userMessage);
await appendMessage(historyPath, sessionId, "assistant", reply);
return {
reply,
sessionId
};
}
这段代码的流程很清楚:
text
用户消息
-> 读取当前 session 的历史记录
-> 加载本地知识库
-> 构造 LangChain messages
-> 调用模型
-> 提取回复内容
-> 写入用户消息和客服回复
-> 返回结果
它没有把所有逻辑塞到一个 prompt 字符串里,而是把"知识库""历史记录""模型调用""记忆写入"拆成独立模块。
这样后面要改 Redis 记忆、数据库知识库、流式输出,都不用推倒重来。
3. 知识库:先别急着上向量数据库
很多人做客服 Agent,第一反应是上 RAG、embedding、向量库。
这个项目没有一开始就这么做。
因为当前知识量很小,直接用 JSON 就够了:
json
{
"brand": "示例品牌:主打高性价比、耐用和真实服务体验。",
"policies": [
"不要承诺未确认的发货时间、退款、赔偿、库存或优惠。",
"信息不足时先礼貌追问订单号、商品型号或具体问题。",
"用户情绪强烈时先安抚,再解释和引导下一步。"
],
"products": [
{
"name": "保温杯",
"facts": [
"316 不锈钢内胆",
"杯盖可拆洗",
"按订单顺序处理发货"
]
}
]
}
加载时把结构化 JSON 转成模型容易读的文本:
js
export async function loadKnowledge(filePath) {
try {
const data = JSON.parse(await fs.readFile(filePath, "utf8"));
return formatKnowledge(data);
} catch (error) {
if (error.code === "ENOENT") return "暂无知识库。";
throw error;
}
}
格式化逻辑也很直接:
js
function formatKnowledge(data) {
const lines = [];
if (data.brand) lines.push(`品牌信息:${data.brand}`);
if (Array.isArray(data.policies) && data.policies.length) {
lines.push("客服规则:");
lines.push(...data.policies.map(item => `- ${item}`));
}
if (Array.isArray(data.products) && data.products.length) {
lines.push("商品信息:");
for (const product of data.products) {
lines.push(`- ${product.name || "未命名商品"}:${(product.facts || []).join(";")}`);
}
}
return lines.join("\n") || "暂无知识库。";
}
这个设计很适合第一版。
如果商品只有几十个,规则也不多,直接塞进 system message,比引入向量库更简单,也更可控。
等知识库变大,再升级到检索:
text
用户问题 -> 检索相关商品/规则 -> 注入 prompt -> 模型回答
不要一开始就把架构做重。
4. 历史记忆:按 sessionId 隔离多轮对话
客服对话最重要的是上下文。
用户第一轮说:
text
我昨天买了保温杯
第二轮问:
text
什么时候发货?
如果没有历史记录,模型不知道"什么时候发货"指的是什么商品。
项目里用本地 JSON 做了一个轻量记忆模块:
js
export async function getMessages(filePath, sessionId, limit = 20) {
const records = await readStore(filePath);
return (records[sessionId] || []).slice(-limit);
}
写入消息:
js
export async function appendMessage(filePath, sessionId, role, content) {
const records = await readStore(filePath);
const messages = records[sessionId] || [];
const message = {
id: crypto.randomUUID(),
role,
content,
createdAt: new Date().toISOString()
};
records[sessionId] = [...messages, message].slice(-100);
await writeStore(filePath, records);
return message;
}
这里有两个限制很关键:
getMessages默认只取最近 20 条appendMessage每个 session 最多保留 100 条
这避免了历史记录无限增长。
多轮对话不是把所有历史都塞给模型。历史越长,token 成本越高,噪音也越多。第一版保留最近消息,是一个务实的选择。
5. Prompt 设计:把客服边界写清楚
真正决定客服质量的,不只是模型能力,还有 prompt 的边界。
项目里的 buildMessages 会生成一个 SystemMessage 和一个 HumanMessage:
js
return [
new SystemMessage(
[
"你是一个智能对话客服 agent。",
"你必须结合知识库和历史消息回复顾客。",
"不要编造商品功能、库存、物流状态、退款、赔偿、优惠或具体时间。",
"如果信息不足,先礼貌追问关键字段。",
"如果顾客不满或着急,先安抚,再给下一步。",
"只回答顾客当前问题,不主动营销或追加无关卖点。",
"少用表情符号,除非顾客语气明显轻松。",
"回复要自然、简洁,可以直接发送给顾客。",
"",
knowledge
].join("\n")
),
new HumanMessage(`历史消息:\n${historyText}\n\n顾客最新消息:${userMessage}`)
];
这里最重要的是这一句:
text
不要编造商品功能、库存、物流状态、退款、赔偿、优惠或具体时间。
客服 Agent 最大的问题不是"不够聪明",而是"太会编"。
用户问:
text
今天能发货吗?
如果模型直接说:
text
可以,今天会发出。
这就是事故。
所以 prompt 必须把禁止项写清楚:
- 不承诺库存
- 不承诺物流时间
- 不承诺退款
- 不承诺赔偿
- 不承诺优惠
- 信息不足先追问
客服场景里,克制比热情重要。
6. 历史消息如何拼进模型输入
项目没有直接把原始 JSON 丢给模型,而是把历史消息转成客服对话格式:
js
const historyText = history.length
? history.map(item => `${item.role === "assistant" ? "客服" : "顾客"}:${item.content}`).join("\n")
: "暂无历史消息。";
最后进入用户消息:
js
new HumanMessage(`历史消息:\n${historyText}\n\n顾客最新消息:${userMessage}`)
模型看到的大概是:
text
历史消息:
顾客:我昨天已经下单了
客服:您好,订单会按顺序处理,暂不能承诺具体时间。
顾客最新消息:什么时候发货?
这样模型能理解"什么时候发货"是在追问上一轮订单。
严格来说,如果继续升级,可以用 MessagesPlaceholder 或 RunnableWithMessageHistory 来管理消息历史。但这个项目的写法更适合教学和第一版落地,因为每一步都显式可见。
7. HTTP API:把 Agent 包成可调用服务
项目提供了 HTTP 接口。
核心路由是 /api/chat:
js
if (req.method === "POST" && url.pathname === "/api/chat") {
const payload = await readJson(req);
if (!config.apiKey) {
sendJson(res, 400, { error: "缺少 AI_API_KEY,请先配置 .env。" });
return;
}
const result = await chat({
sessionId: payload.sessionId || "default",
message: payload.message,
model
});
sendJson(res, 200, result);
return;
}
接口设计很简单:
json
{
"sessionId": "demo",
"message": "我昨天买的保温杯什么时候发货?"
}
返回:
json
{
"reply": "...",
"sessionId": "demo"
}
这里的 sessionId 是多轮对话的关键。
同一个 sessionId 会读取同一份历史。不同 sessionId 会隔离历史。
项目还提供了两个辅助接口:
text
GET /api/history?sessionId=demo
POST /api/history/clear
这对调试很有用。
做 Agent 应用时,一定要能看到历史记录。否则模型答错了,你很难判断是知识库问题、历史问题,还是 prompt 问题。
8. CLI 入口:本地调试比接口调试更快
除了 HTTP 服务,项目还有 CLI:
bash
npm run cli
核心代码:
js
const sessionId = `cli-${Date.now()}`;
const model = createModel(config);
console.log("智能客服 Agent 已启动。输入 exit 退出。");
while (true) {
const message = (await rl.question("顾客> ")).trim();
if (!message) continue;
if (["exit", "quit", "退出"].includes(message.toLowerCase())) break;
try {
const result = await chat({ sessionId, message, model });
console.log(`客服> ${result.reply}\n`);
} catch (error) {
console.error(`错误> ${error.message}\n`);
}
}
CLI 的价值是反馈快。
调 prompt、调知识库、调多轮上下文时,先用 CLI 跑几轮,比每次写 curl 更舒服。
等效果稳定了,再通过 HTTP 接到前端或业务系统。
9. 错误处理:模型接口失败要说人话
模型调用最常见的问题是:
- API Key 没配
- baseURL 配错
- 网络超时
- 请求 JSON 格式错误
- 请求体太大
项目里对这些情况做了基础处理。
比如没有 API Key:
js
if (!config.apiKey) {
sendJson(res, 400, { error: "缺少 AI_API_KEY,请先配置 .env。" });
return;
}
模型超时:
js
function normalizeError(error) {
const message = error.cause?.message || error.message || "服务错误";
if (/timeout|timed out|Connect Timeout|Abort/i.test(message)) {
return "模型接口连接超时,请检查网络、AI_BASE_URL 或 API Key。";
}
return message;
}
这点很实际。
用户不需要看到一长串 SDK 报错。开发阶段也一样,错误信息越清楚,定位越快。
10. 测试:Agent 项目也要测"上下文是否真的进去了"
这个项目不是只测工具函数。
agent.test.js 里测了一个很关键的行为:第二轮对话时,模型输入里必须包含知识库和上一轮历史。
测试里没有真的调用大模型,而是传入一个假的 model:
js
const calls = [];
const model = {
invoke: async messages => {
calls.push(messages);
return { content: "您好,订单会按顺序处理,暂不能承诺具体时间。" };
}
};
然后连续调用两轮:
js
await chat({
sessionId: "s1",
message: "我昨天已经下单了",
model,
historyPath,
knowledgePath
});
const second = await chat({
sessionId: "s1",
message: "什么时候发货?",
model,
historyPath,
knowledgePath
});
最后断言模型收到的内容里包含:
js
assert.match(serialized, /测试品牌/);
assert.match(serialized, /我昨天已经下单了/);
assert.match(serialized, /什么时候发货/);
这比测试"函数返回了什么字符串"更重要。
因为 Agent 应用的关键不是某一次模型回复长什么样,而是输入上下文有没有构造正确。
memory.test.js 则验证了两件事:
- 同一个 session 能读到自己的消息
- 清空一个 session 不影响另一个 session
这正好对应多轮客服的核心要求:会话隔离。
11. 怎么运行这个项目
安装依赖:
bash
npm install
复制环境变量:
bash
cp .env.example .env
填写:
bash
AI_API_KEY=你的百炼 Key
AI_BASE_URL=https://dashscope.aliyuncs.com/compatible-mode/v1
AI_MODEL=qwen-plus
AI_TIMEOUT_MS=30000
PORT=5188
启动 CLI:
bash
npm run cli
启动 HTTP 服务:
bash
npm run dev
调用接口:
bash
curl -X POST http://127.0.0.1:5188/api/chat \
-H 'Content-Type: application/json' \
-d '{"sessionId":"demo","message":"我昨天买的保温杯什么时候发货?"}'
查看历史:
bash
curl 'http://127.0.0.1:5188/api/history?sessionId=demo'
清空历史:
bash
curl -X POST http://127.0.0.1:5188/api/history/clear \
-H 'Content-Type: application/json' \
-d '{"sessionId":"demo"}'
运行测试:
bash
npm test
12. 这个项目还可以怎么升级
第一,接入真正的检索。
当前知识库是一次性注入 prompt。商品和规则变多后,可以改成:
text
用户问题 -> 检索相关知识 -> 注入 prompt -> 模型回答
第二,把 JSON 记忆换成 Redis 或数据库。
本地 JSON 适合 demo 和单机调试。生产环境要考虑多实例、并发写入、过期时间和数据清理。
第三,加入结构化输出。
比如让模型额外输出:
json
{
"intent": "shipping_query",
"needHuman": false,
"reply": "..."
}
这样后续可以接人工客服、工单系统或风控逻辑。
第四,引入工具调用。
比如:
- 查询订单状态
- 查询物流轨迹
- 查询售后规则
- 创建人工工单
但工具调用一定要加边界。退款、赔偿、改地址这类动作,不应该让模型直接执行,至少要有人审或业务规则校验。
13. 总结
这个项目的重点不是用了多少高级 API。
它真正有价值的地方是把客服 Agent 的基础工程问题拆清楚了:
- 模型创建独立放在
model.js - 知识库独立放在
knowledge.js - 历史记忆独立放在
memory.js - 对话编排集中在
agent.js - CLI 和 HTTP 分别作为入口
- 测试重点验证上下文和会话隔离
LangChain 实战里,最容易踩的坑是太早追求复杂。
先把知识、历史、prompt、模型调用、接口、测试这些基础做好,一个客服 Agent 才有继续升级的空间。
能稳定回答、能追踪历史、能被测试验证,才是第一版智能客服最重要的目标。