LangSmith 全链路观测(上):用 LangGraph 与 Milvus 构建可追踪的 RAG 客服

LangSmith 全链路观测与 RAG 量化评估(上):用 LangGraph、Milvus 搭建可追踪的客服 Agent

大模型应用最让人头疼的地方,往往不是"第一次把它跑起来",而是运行结果带来的盲盒感:

  • 用户的问题究竟召回了哪些资料?
  • 模型为什么生成了这句话?
  • 是检索错了,还是模型没有正确使用检索结果?
  • 某个节点为什么变慢,错误又发生在哪一步?

如果整个应用只是一次黑盒式的模型调用,这些问题很难回答。本文会实现一个电商客服 RAG Agent,并从一开始就保留清晰的状态、检索上下文和运行轨迹。

最终链路如下:

text 复制代码
离线入库:业务文档 -> 文档切块 -> Embedding -> Milvus

在线问答:用户问题 -> Embedding -> Milvus Top 4 检索
                              -> 上下文 + 问题 -> 大模型 -> 答案

运行观测:LangGraph 节点执行 -> LangSmith Trace

本文重点不是做一个复杂的客服系统,而是把 RAG 中最值得观察和评估的边界显式地保留下来。下篇将在这个基础上继续搭建数据集和自动评估器。

一、技术栈与环境变量

本文使用的核心依赖如下:

bash 复制代码
pnpm add \
  @langchain/community \
  @langchain/core \
  @langchain/langgraph \
  @langchain/openai \
  @langchain/textsplitters \
  @zilliz/milvus2-sdk-node \
  dotenv \
  langsmith \
  openevals

其中各组件的职责非常明确:

  • @langchain/textsplitters:把长文档切成可检索的文本块。
  • @langchain/openai:提供对话模型和 Embedding 模型的统一调用接口。
  • @zilliz/milvus2-sdk-node:创建 Collection、索引并写入向量。
  • @langchain/community/vectorstores/milvus:在问答阶段把已有的 Milvus Collection 包装成 Retriever。
  • @langchain/langgraph:把检索与生成组织为带状态的图。
  • langsmith:记录运行轨迹,并在后续承载数据集和实验。
  • openevals:在下篇中提供 RAG 评估 Prompt 与 LLM Judge。

环境变量可以按下面的结构配置,具体模型名由实际服务决定:

dotenv 复制代码
# LangSmith
LANGCHAIN_API_KEY=你的_LangSmith_Key
LANGCHAIN_PROJECT=你的项目名
LANGSMITH_TRACING=true

# 模型服务
OPENAI_API_KEY=你的模型服务_Key
OPENAI_BASE_URL=兼容_OpenAI_协议的服务地址
MODEL_NAME=对话模型名
EMBEDDINGS_MODEL_NAME=向量模型名

# Milvus
MILVUS_ADDRESS=localhost:19350
MILVUS_COLLECTION=rag_docs

这里使用 configuration.baseURL,因此对话模型与向量模型都可以连接兼容 OpenAI 接口规范的服务。API Key 不要写死在代码中,也不要提交到版本管理系统。

另一个容易混淆的点是:

  • OPENAI_API_KEY 用于调用模型服务;
  • LANGCHAIN_API_KEY 用于连接 LangSmith;
  • LANGSMITH_TRACING=true 用于开启追踪;
  • LANGCHAIN_PROJECT 用于把 Trace 归类到指定项目。

它们属于两条不同的连接链路。

二、准备一份能验证检索效果的知识库

示例知识库覆盖五类客服信息:

  1. 售后:7 天无理由退货、15 天质量问题换货、运费和退款时效;
  2. 物流:发货时效、包邮门槛、配送范围、签收与验货;
  3. 支付:支付方式、分期门槛、价保与发票;
  4. 会员:会员折扣、积分抵扣、账号安全;
  5. 质保:数码、家电、家具、食品与美妆的不同规则。

这样的数据设计有两个好处。

首先,问题之间有足够差异,能够观察语义检索是否找对主题。例如"积分多少可以抵 1 元"和"手机保修多久"理应命中完全不同的文档。

其次,同一个主题下存在相似但不同的规则。例如"无理由退货"和"质量问题换货"的期限、运费承担方都不同,可以检查模型是否会把相邻知识混在一起。

知识文件可以使用 .md 或 .txt。读取时要特别注意扩展名过滤:如果正则只匹配 .text 和 .md,常见的 .txt 文件就会被静默漏掉。售后规则一旦放在 .txt 中,"7 天无理由退货"之类的问题就不可能被正确召回。

三、离线入库:从文本到 Milvus

3.1 读取文档并保留来源

先把目录中的 Markdown 与文本文件转换成 LangChain Document 所需的结构:

js 复制代码
import "dotenv/config";
import { existsSync, readFileSync, readdirSync } from "fs";
import { join } from "path";
import { RecursiveCharacterTextSplitter } from "@langchain/textsplitters";

async function loadChunks(dataDir = "./data") {
  if (!existsSync(dataDir)) {
    throw new Error(`Data directory ${dataDir} does not exist`);
  }

  const files = readdirSync(dataDir).filter((file) =>
    /\.(txt|text|md)$/.test(file)
  );

  if (files.length === 0) {
    throw new Error(`no .txt/.text/.md files found in ${dataDir}`);
  }

  const docs = files.map((file) => ({
    pageContent: readFileSync(join(dataDir, file), "utf-8"),
    metadata: {
      source: file,
    },
  }));

  const splitter = new RecursiveCharacterTextSplitter({
    chunkSize: 500,
    chunkOverlap: 50,
  });

  return splitter.splitDocuments(docs);
}

每个 Document 包含两个关键部分:

  • pageContent 是真正参与切块、向量化和检索的正文;
  • metadata.source 记录文本来自哪个业务文档。

来源信息虽然不参与当前回答,却会随着检索结果一起返回。后面排查召回问题时,它能告诉我们某个文本块来自售后、物流还是会员规则。

这里使用同步文件 API 是因为入库属于一次性的离线任务,逻辑简单、顺序清晰。它并不适合直接放到需要高并发响应请求的服务接口里。

3.2 为什么要切块,以及为什么有重叠

示例参数是:

js 复制代码
const splitter = new RecursiveCharacterTextSplitter({
  chunkSize: 500,
  chunkOverlap: 50,
});

chunkSize: 500 表示每个文本块的目标大小为 500 个字符;chunkOverlap: 50 表示相邻文本块保留 50 个字符的重叠。

切块不能只理解为"把长文档切短"。它还决定了检索的最小信息单元:

  • 块太大,一个向量中会混入多个主题;
  • 块太小,一条完整规则可能被拆断;
  • 完全不重叠,恰好处在边界上的句子容易失去上下文;
  • 适量重叠可以让跨边界信息在相邻块中保留。

这里的 500/50 是一个明确、易观察的起点。后续评测如果发现召回结果经常只有半句话,再调整切块参数才有依据。

3.3 批量生成向量,并从结果推导维度

Embedding 客户端同时用于离线文档和在线问题:

js 复制代码
import { OpenAIEmbeddings } from "@langchain/openai";

const embeddings = new OpenAIEmbeddings({
  apiKey: process.env.OPENAI_API_KEY,
  model: process.env.EMBEDDINGS_MODEL_NAME,
  configuration: {
    baseURL: process.env.OPENAI_BASE_URL,
  },
});

const vectors = await embeddings.embedDocuments(
  chunks.map((chunk) => chunk.pageContent)
);

const dim = vectors[0].length;

embedDocuments() 接收一组文本,并返回一组浮点数向量。第 i 个向量必须与第 i 个文本块一一对应。

Milvus 的 FloatVector 字段在建表时必须声明维度。这里不把维度硬编码为某个数字,而是使用 vectors[0].length 从实际模型输出推导。这样做能避免"更换 Embedding 模型后,代码中的维度没有同步修改"的问题。

不过,动态读取维度不意味着可以随意更换模型。入库和查询必须使用同一个 Embedding 模型,否则即使向量长度碰巧相同,它们也不处于同一套语义空间中,距离就失去了意义。

3.4 创建 Collection:字段名要与检索适配器一致

连接 Milvus:

js 复制代码
import {
  MilvusClient,
  DataType,
  IndexType,
  MetricType,
} from "@zilliz/milvus2-sdk-node";

const COLLECTION_NAME = process.env.MILVUS_COLLECTION ?? "rag_docs";
const MILVUS_ADDRESS =
  process.env.MILVUS_ADDRESS?.replace(/^https?:\/\//, "") ??
  "localhost:19350";

const client = new MilvusClient({
  address: MILVUS_ADDRESS,
});

await client.connectPromise;

然后创建 Collection:

js 复制代码
await client.createCollection({
  collection_name: COLLECTION_NAME,
  fields: [
    {
      name: "langchain_primaryid",
      is_primary_key: true,
      data_type: DataType.Int64,
      autoID: true,
    },
    {
      name: "langchain_vector",
      data_type: DataType.FloatVector,
      dim,
    },
    {
      name: "langchain_text",
      data_type: DataType.VarChar,
      max_length: 8000,
    },
    {
      name: "source",
      data_type: DataType.VarChar,
      max_length: 256,
    },
  ],
});

四个字段分别承担以下职责:

字段 作用
langchain_primaryid 自动生成的主键
langchain_vector 文本块对应的向量
langchain_text 原始文本块
source 文本来源

前三个字段名不是随意命名的。后续会使用 LangChain 的 Milvus VectorStore 读取这个 Collection,它默认识别的正是 langchain_primaryid、langchain_vector 和 langchain_text。

如果手动建表时使用了 id、vector、content,读取时就必须显式告诉适配器这些自定义字段名。入库端和检索端必须共享同一份字段契约。

3.5 IVF_FLAT、L2、nlist 与 nprobe

向量索引使用 IVF_FLAT:

js 复制代码
await client.createIndex({
  collection_name: COLLECTION_NAME,
  field_name: "langchain_vector",
  index_type: IndexType.IVF_FLAT,
  metric_type: MetricType.L2,
  params: {
    nlist: 128,
  },
});

可以把 IVF_FLAT 简化理解为"两阶段搜索":

  1. 建索引时先把向量划分到多个桶中;
  2. 查询时先选择若干候选桶,再在桶内计算向量距离。

nlist: 128 表示索引建立 128 个桶。查询阶段还需要 nprobe,它决定一次搜索多少个桶。搜索更多桶通常会带来更高的召回机会,也会增加计算量。

距离度量使用 L2,即欧氏距离。最重要的不是单独记住某个参数,而是确保建索引和查询时使用相同的索引类型、距离类型及匹配的查询参数。

3.6 写入数据并确保落盘完成

文本、来源与向量按下标合并:

js 复制代码
const data = chunks.map((chunk, index) => ({
  langchain_text: chunk.pageContent,
  source: chunk.metadata.source,
  langchain_vector: vectors[index],
}));

const result = await client.insert({
  collection_name: COLLECTION_NAME,
  data,
});

await client.flushSync({
  collection_names: [COLLECTION_NAME],
});

await client.loadCollection({
  collection_name: COLLECTION_NAME,
});

console.log(`Inserted ${result.insert_cnt} records`);

flushSync() 会等待 Flush 完成,适合"入库脚本结束后马上启动检索"的演示流程。Collection 加载后才能用于搜索。

为了方便反复演示,可以在建表前删除同名 Collection:

js 复制代码
const exists = await client.hasCollection({
  collection_name: COLLECTION_NAME,
});

if (exists.value) {
  await client.dropCollection({
    collection_name: COLLECTION_NAME,
  });
}

但要明确:这是"全量重建",不是增量更新。每次运行都会删除旧数据,适合练习和可重复实验,不适合直接当成生产更新策略。

四、在线检索:复用已有的 Milvus Collection

4.1 查询端必须复用相同的 Embedding 配置

js 复制代码
import { Milvus } from "@langchain/community/vectorstores/milvus";
import { OpenAIEmbeddings } from "@langchain/openai";

const embeddings = new OpenAIEmbeddings({
  apiKey: process.env.OPENAI_API_KEY,
  model: process.env.EMBEDDINGS_MODEL_NAME,
  configuration: {
    baseURL: process.env.OPENAI_BASE_URL,
  },
});

文档入库时调用 embedDocuments(),查询时 Retriever 会对问题调用查询向量化。两端使用同一模型,是向量检索成立的前提。

4.2 让查询参数与已有索引对齐

读取已有 Collection 时,显式声明索引配置:

js 复制代码
const vectorStore = await Milvus.fromExistingCollection(embeddings, {
  collectionName: process.env.MILVUS_COLLECTION ?? "rag_docs",
  url: process.env.MILVUS_ADDRESS,
  indexCreateOptions: {
    index_type: "IVF_FLAT",
    metric_type: "L2",
    params: {
      nlist: 128,
    },
    search_params: {
      nprobe: 10,
    },
  },
});

const retriever = vectorStore.asRetriever({ k: 4 });

这里有一个很隐蔽但很重要的边界:fromExistingCollection() 表示"连接已有 Collection",并不代表调用端可以忘记索引的搜索参数。

如果写入端建立的是 IVF_FLAT,查询端却继续使用适配器的另一套默认参数,就可能把不属于 IVF_FLAT 的搜索参数发给 Milvus。显式写出 IVF_FLAT + L2 + nprobe,既避免配置漂移,也让代码本身准确描述了向量库中的真实索引。

k: 4 表示每次返回最相关的 4 个 Document。这个参数同时影响上下文质量和 Prompt 长度:

  • 太少可能漏掉回答所需的信息;
  • 太多可能把无关规则送给模型;
  • 固定为 4 后,可以在后续评测中观察它是否足够。

五、把 Prompt、模型和输出解析器串成生成链

初始化对话模型:

js 复制代码
import { ChatOpenAI } from "@langchain/openai";

const llm = new ChatOpenAI({
  apiKey: process.env.OPENAI_API_KEY,
  model: process.env.MODEL_NAME,
  temperature: 0,
  configuration: {
    baseURL: process.env.OPENAI_BASE_URL,
  },
});

temperature: 0 的目的不是保证每次输出逐字相同,而是尽量降低生成随机性,让调试和评测时的变量更少。

客服 Prompt 只允许模型使用检索到的上下文:

js 复制代码
import { ChatPromptTemplate } from "@langchain/core/prompts";

const prompt = ChatPromptTemplate.fromMessages([
  [
    "system",
    `你是客服助手。仅根据下面[上下文]回答:
上下文没有的信息请明确说明不知道,不要编造。

上下文:{context}`,
  ],
  ["human", "{question}"],
]);

这段约束同时规定了两个行为:

  1. 有依据时,答案必须来自 context;
  2. 没有依据时,允许并要求回答"不知道"。

RAG 并不是强迫模型回答所有问题。对于知识库之外的问题,拒绝编造本身就是正确行为。

接着用 RunnableSequence 把三步串起来:

js 复制代码
import { StringOutputParser } from "@langchain/core/output_parsers";
import { RunnableSequence } from "@langchain/core/runnables";

const chain = RunnableSequence.from([
  prompt,
  llm,
  new StringOutputParser(),
]);

数据依次流过:

text 复制代码
{ context, question }
        ↓
格式化后的消息列表
        ↓
模型响应对象
        ↓
纯字符串答案

StringOutputParser 让生成节点只关心最终文本,不需要在后续代码中反复读取消息对象的 content。

六、用 LangGraph 显式表达 RAG 状态

6.1 三个状态字段

js 复制代码
import {
  Annotation,
  END,
  START,
  StateGraph,
} from "@langchain/langgraph";

const GraphState = Annotation.Root({
  question: Annotation,
  context: Annotation,
  answer: Annotation,
});

这三个字段正好对应 RAG 的三个关键观察点:

字段 内容 用途
question 原始用户问题 作为检索与生成输入
context Retriever 返回的 Document 数组 检查召回质量
answer 模型最终回答 检查生成质量

如果 ask() 最后只返回答案,评测时就无法区分"检索错了"和"生成错了"。因此,context 不是调试期间临时打印一下就算结束,它应该成为应用输出的一部分,供后续评估适配层使用。

6.2 检索节点

js 复制代码
async function retrieve(state) {
  const docs = await retriever.invoke(state.question);
  return {
    context: docs,
  };
}

节点接收当前状态,只返回自己负责更新的字段。LangGraph 会把 { context: docs } 合并回图状态。

6.3 生成节点

js 复制代码
async function generate(state) {
  const contextText = state.context
    .map((doc) => doc.pageContent)
    .join("\n\n");

  const answer = await chain.invoke({
    context: contextText,
    question: state.question,
  });

  return {
    answer,
  };
}

Retriever 返回的是 Document 数组,而 Prompt 的 {context} 需要字符串。这里用两个换行拼接每个文本块,使不同片段在 Prompt 中保留可见边界。

同时要注意:只有正文被送入模型,source 等元数据仍保留在原始 Document 中,用于调试和评测。

6.4 编排并编译图

js 复制代码
const workflow = new StateGraph(GraphState)
  .addNode("retrieve", retrieve)
  .addNode("generate", generate)
  .addEdge(START, "retrieve")
  .addEdge("retrieve", "generate")
  .addEdge("generate", END);

export const ragApp = workflow.compile();

图的执行顺序非常直观:

text 复制代码
START -> retrieve -> generate -> END

虽然当前只有两个业务节点,使用图仍然有价值,因为 Trace 会天然呈现检索与生成的边界。以后看到错误或耗时异常时,不需要先从一大段串行函数中猜测问题属于哪一步。

最后封装一个稳定的调用接口:

js 复制代码
export async function ask(question) {
  const result = await ragApp.invoke({ question });

  return {
    answer: result.answer,
    context: result.context ?? [],
  };
}

?? [] 保证上下文缺失时仍返回数组,减少 CLI 和评测代码的分支判断。

七、用 CLI 同时查看答案与检索上下文

一个最小 CLI 就能完成端到端验证:

js 复制代码
import "dotenv/config";
import { ask } from "./rag_agent.mjs";

const DEFAULT_QUESTIONS = ["无理由退货要在几天内?"];
const args = process.argv.slice(2);
const questions = args.length > 0
  ? [args.join(" ")]
  : DEFAULT_QUESTIONS;

for (let index = 0; index < questions.length; index++) {
  const question = questions[index];
  const { answer, context } = await ask(question);

  console.log(`问题 ${index + 1}:${question}`);
  console.log(`回答:${answer}`);
  console.log("------------");
  console.log(context);
}

之所以同时打印 answer 和 context,是因为只看答案容易得出错误结论。例如模型答对了"7 天",可能是正确检索了售后规则,也可能是碰巧使用了模型已有知识。把上下文一起输出,才能验证回答是否真的来自知识库。

运行顺序是先入库,再提问:

bash 复制代码
node src/milvus_insert.mjs
node src/cli.mjs
node src/cli.mjs "金卡会员有什么折扣?"

八、LangSmith Trace:不仅记录成功,也要记录失败

当环境变量开启追踪后,LangChain 与 LangGraph 的运行会自动进入 LangSmith。除了完整的 RAG 调用,还可以构造一个故意失败的最小图,验证错误是否出现在 Trace 中。

8.1 定义带 reducer 的状态

js 复制代码
import "dotenv/config";
import {
  Annotation,
  END,
  START,
  StateGraph,
} from "@langchain/langgraph";

const StateAnnotation = Annotation.Root({
  text: Annotation({
    reducer: (_previous, next) => next,
    default: () => "",
  }),
});

default 给 text 提供空字符串初值;reducer 规定新值覆盖旧值。即使当前流程很简单,状态更新规则也是显式的。

8.2 一个成功节点和一个失败节点

js 复制代码
const stepOk = (state) => ({
  text: `${state.text}[ok]`,
});

const stepThrow = () => {
  throw new Error("DemoError:节点内故意抛错");
};

组装执行图:

js 复制代码
const graph = new StateGraph(StateAnnotation)
  .addNode("step_ok", stepOk)
  .addNode("step_throw", stepThrow)
  .addEdge(START, "step_ok")
  .addEdge("step_ok", "step_throw")
  .addEdge("step_throw", END)
  .compile();

try {
  await graph.invoke({ text: "start" });
  console.log("不应该执行到这里");
} catch (error) {
  console.error("已捕获:", error?.message ?? error);
  process.exitCode = 1;
}

本地 try...catch 负责让命令行得到明确错误并设置非零退出码;LangSmith Trace 则负责保留图运行过程。二者并不冲突。

观察这条失败 Trace 时,应该能清楚地区分:

  • 图已经进入 step_ok,状态从 start 变为 start[ok];
  • 错误发生在 step_throw;
  • 后续流程没有正常结束;
  • 错误消息是主动抛出的 DemoError。

这个小实验比直接拿复杂 RAG 流程测试追踪更容易验证配置是否生效。

九、从 Trace 定位 RAG 问题

有了节点边界之后,一次问答可以按下面的顺序排查。

情况一:检索节点返回空数组

优先检查:

  • Collection 名称是否一致;
  • 知识文件是否因扩展名过滤而漏读;
  • 入库是否成功;
  • Collection 是否已加载;
  • 查询模型是否与入库模型一致。

情况二:有上下文,但内容与问题无关

优先检查:

  • 切块是否把完整规则拆散;
  • k: 4 是否带回了过多噪声或遗漏信息;
  • IVF_FLAT 的 nprobe 是否与预期一致;
  • Embedding 模型配置是否发生过变化。

情况三:上下文正确,答案却错误

这时问题更可能位于生成阶段:

  • Prompt 是否真的包含 {context};
  • 上下文拼接是否为空;
  • 模型是否混合了多个相似规则;
  • "仅根据上下文回答"的约束是否被遵守。

情况四:答案正确,但响应很慢

通过节点耗时可以判断主要延迟来自检索、Embedding 还是生成。只有把步骤拆开,性能问题才不是一个模糊的"整个 Agent 很慢"。

十、这套实现中最容易忽略的契约

最后把关键约束集中列出来:

  1. 文件扩展名契约 :过滤器必须覆盖实际使用的 .txt 与 .md。
  2. Document 契约 :正文放在 pageContent,来源放在 metadata.source。
  3. 向量契约:入库与查询必须使用同一个 Embedding 模型。
  4. 维度契约 :Milvus FloatVector 的 dim 必须等于模型实际输出长度。
  5. 字段契约:手动创建的字段名必须与 LangChain Milvus 适配器一致。
  6. 索引契约 :建索引与查询必须统一 IVF_FLAT、L2 及对应搜索参数。
  7. 输出契约 :ask() 不只返回答案,还要返回原始检索上下文。
  8. 观测契约:成功运行和异常运行都应进入 Trace。
  9. 数据更新契约:删除 Collection 后重建是全量覆盖,不是增量写入。

这些契约看起来分别属于文件读取、向量模型、数据库和 Agent,但它们共同决定了最终答案是否可信。RAG 的错误往往不是某一行代码语法错误,而是两个阶段对同一件事的理解不一致。

总结

到这里,我们已经完成了一条边界清晰的 RAG 链路:

text 复制代码
业务知识
  -> 切块并保留来源
  -> 生成向量
  -> 写入 Milvus
  -> Top 4 检索
  -> 拼接上下文
  -> 模型生成答案
  -> LangSmith 记录运行轨迹

Trace 可以回答"一次调用发生了什么",却还不能回答"这个版本整体表现得怎么样"。下一篇会把 12 组客服问答上传为 LangSmith Dataset,再通过 groundedness、helpfulness 和 retrieval relevance 三类评估器,把 RAG 的检索与生成质量拆开量化。

相关推荐
半个落月2 小时前
LangChain.js Agent 中间件实战:生命周期、动态工具与文件权限
javascript·langchain
志尊宝3 小时前
Vue3 零基础每日笔记(082):文件上传实战——el-upload 校验、自定义上传、头像预览
前端·javascript·vue.js·笔记·#javascript·#vue.js
10年前端老司机6 小时前
我用 Vue3 + LangGraph 做了个心理咨询 AI,踩了 9 个坑后总结出来的实战经验
前端·langchain·agent
bmxy小明同学9 小时前
2026-10-11-幻觉治理
服务器·前端·javascript
m0_587383009 小时前
无人自助拼豆系统落地开发实战指南 核心逻辑实现与部署经验分享
前端·javascript·vue.js·人工智能·经验分享·需求分析
zhaiyy9 小时前
HTML Popover API 实践
前端·javascript·html
一木 之林9 小时前
LangChain 第3章 消息与提示词模板:四类消息、历史管理与 ChatPromptTemplate
人工智能·计算机视觉·langchain
OpsEye10 小时前
企业 AI 管理新需求:不仅要接入模型,还要看懂每一次调用
javascript·ai编程