LangChain 实战:从 0 搭一个带历史记忆的智能客服 Agent

项目源码:

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 复制代码
历史消息:
顾客:我昨天已经下单了
客服:您好,订单会按顺序处理,暂不能承诺具体时间。

顾客最新消息:什么时候发货?

这样模型能理解"什么时候发货"是在追问上一轮订单。

严格来说,如果继续升级,可以用 MessagesPlaceholderRunnableWithMessageHistory 来管理消息历史。但这个项目的写法更适合教学和第一版落地,因为每一步都显式可见。

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 才有继续升级的空间。

能稳定回答、能追踪历史、能被测试验证,才是第一版智能客服最重要的目标。

相关推荐
前进的李工5 小时前
智能Agent实战指南:记忆组件嵌入技巧(记忆)
开发语言·前端·javascript·python·langchain·agent
ftpeak6 小时前
AI开发之LangGraph教程4~记忆 (Memory)
python·ai·langchain·langgraph
终生成长者7 小时前
04LangChain SQL 问答系统知识点详解
数据库·python·sql·langchain
狐狐生风8 小时前
LangGraph 重构个人知识库问答系统(稳定 + 可扩展版)
python·langchain·rag·langgraph·agentai
JaydenAI10 小时前
[Deep Agents:LangChain的Agent Harness-04]TodoListMiddleware的任务拆解与状态流转
langchain·todolist·middleware·deep agents
MY_TEUCK10 小时前
【MY_TRUCK - AI 应用】RAG 与 LangChain 入门:检索增强生成、向量检索与链式编排
人工智能·机器学习·langchain
狐狐生风10 小时前
LangGraph 核心概念全解笔记
人工智能·python·langchain·prompt·langgraph
@atweiwei11 小时前
LangChainRust Agent 引擎:Graph 构建到执行
rust·langchain·llm·agent·rag·langchaingraph
JaydenAI11 小时前
[Deep Agents:LangChain的Agent Harness-03]FilesystemMiddleware:赋能Agent读写文件及管理长上下文
langchain·filesystem·middleware·deep agents