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 归类到指定项目。
它们属于两条不同的连接链路。
二、准备一份能验证检索效果的知识库
示例知识库覆盖五类客服信息:
- 售后:7 天无理由退货、15 天质量问题换货、运费和退款时效;
- 物流:发货时效、包邮门槛、配送范围、签收与验货;
- 支付:支付方式、分期门槛、价保与发票;
- 会员:会员折扣、积分抵扣、账号安全;
- 质保:数码、家电、家具、食品与美妆的不同规则。
这样的数据设计有两个好处。
首先,问题之间有足够差异,能够观察语义检索是否找对主题。例如"积分多少可以抵 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 简化理解为"两阶段搜索":
- 建索引时先把向量划分到多个桶中;
- 查询时先选择若干候选桶,再在桶内计算向量距离。
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}"],
]);
这段约束同时规定了两个行为:
- 有依据时,答案必须来自
context; - 没有依据时,允许并要求回答"不知道"。
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 很慢"。
十、这套实现中最容易忽略的契约
最后把关键约束集中列出来:
- 文件扩展名契约 :过滤器必须覆盖实际使用的
.txt与.md。 - Document 契约 :正文放在
pageContent,来源放在metadata.source。 - 向量契约:入库与查询必须使用同一个 Embedding 模型。
- 维度契约 :Milvus FloatVector 的
dim必须等于模型实际输出长度。 - 字段契约:手动创建的字段名必须与 LangChain Milvus 适配器一致。
- 索引契约 :建索引与查询必须统一
IVF_FLAT、L2及对应搜索参数。 - 输出契约 :
ask()不只返回答案,还要返回原始检索上下文。 - 观测契约:成功运行和异常运行都应进入 Trace。
- 数据更新契约:删除 Collection 后重建是全量覆盖,不是增量写入。
这些契约看起来分别属于文件读取、向量模型、数据库和 Agent,但它们共同决定了最终答案是否可信。RAG 的错误往往不是某一行代码语法错误,而是两个阶段对同一件事的理解不一致。
总结
到这里,我们已经完成了一条边界清晰的 RAG 链路:
text
业务知识
-> 切块并保留来源
-> 生成向量
-> 写入 Milvus
-> Top 4 检索
-> 拼接上下文
-> 模型生成答案
-> LangSmith 记录运行轨迹
Trace 可以回答"一次调用发生了什么",却还不能回答"这个版本整体表现得怎么样"。下一篇会把 12 组客服问答上传为 LangSmith Dataset,再通过 groundedness、helpfulness 和 retrieval relevance 三类评估器,把 RAG 的检索与生成质量拆开量化。