别再"盲盒式"调 Agent 了!LangSmith 全链路观测 + RAG 量化评估实战(LangGraph × Milvus)
如果你无法度量它,你就无法管理它。
用 LangChain / LangGraph 写 Agent,最大的痛苦不是写不出来,而是写出来之后那种强烈的**"盲盒感"**:调用了哪个工具?每一步耗时多少?流式输出的 Token 消耗了多少?回答到底是"真懂"还是"瞎编"?
这篇文章用一个完整的 RAG 项目,把 LangSmith 从 Trace 追踪 → Dataset 数据集 → LLM-as-Judge 评估器 → 批量实验 的全链路串起来,帮你把"盲盒"变成一块随时能看的仪表盘。
目录
- 一、先搞清楚:没有观测的 Agent 有多难维护
- 二、LangSmith 的四大核心能力
- 三、动手:搭一个"可被观测"的 RAG Agent
- 四、Trace 是怎么"自动"接上的
- 五、异常验证:图报错也能被完整捕获
- 六、Dataset:把"标准答案"沉淀成可复用资产
- 七、Evaluators:用 LLM-as-Judge 量化 RAG 的三要素
- 八、跑批评估:一条命令产出量化分数
- 九、结果解读:从"打分"到"优化闭环"
- 十、速查表(收藏用)
一、先搞清楚:没有观测的 Agent 有多难维护
传统后端服务出问题,你有日志、有链路追踪、有 Prometheus 指标。而 Agent 出问题时,你往往只有一句"它答得不对"。
具体来说,Agent 的"黑盒"体现在四个层面:
| 层面 | 你会遇到的困惑 | 需要回答的问题 |
|---|---|---|
| 调用链 | 一个问题背后跑了哪些节点? | 是 retrieve 挂了,还是 generate 挂了? |
| 性能 | 用户等了 8 秒,时间花在哪? | 检索慢?还是 LLM 首 Token 慢? |
| 成本 | 这个月 Token 花了多少钱? | 是 Prompt 太长,还是检索塞了太多文档? |
| 质量 | 回答"看起来对",真的对吗? | 是幻觉?是答非所问?还是根本没检索到? |
没有客观测量,就没有优化的方向。 你只能靠"改一版 Prompt 试试看"这种玄学迭代,而每次改动是否真的变好,无人知晓。
这就是 LangSmith 存在的意义:它不是可选项,而是 Agent 工程的仪表盘。
二、LangSmith 的四大核心能力
先用一张表把它们记住,后面全部会用到:
| 能力 | 作用 | 解决什么问题 |
|---|---|---|
| Tracing(追踪) | 记录每次 Agent 执行的完整调用树 | 调试 bug、定位是哪个节点/工具出错 |
| Monitoring(监控) | 后台实时看延迟、Token 开销、工具调用 | 上线后的性能与成本观测 |
| Datasets(数据集) | 统一存放"用户提问 + 标准答案" | 建立可回归的测试样本集 |
| Evaluators(评估器) | 对 Agent 的回答自动打分 | 批量自动化评测,量化回答质量 |
一句话总结工作流:
flowchart LR
A[开发 Agent] --> B[Tracing 看调用链]
B --> C[Dataset 沉淀样本]
C --> D[Evaluators 自动打分]
D --> E[Experiment 对比实验]
E --> F[优化 Agent / RAG 逻辑]
F --> B
Trace Graph 让你一眼看到 Agent 的整体运行情况;Datasets + Evaluators 则把"感觉变好了"变成"分数从 0.62 提到了 0.91"。
三、动手:搭一个"可被观测"的 RAG Agent
整个项目的文件结构如下:
rag-observability/
├── .env
├── data/ # 知识库原始文档(.txt / .md)
├── scripts/
│ ├── milvus_insert.mjs # 1. 切分 + 向量化 + 写入 Milvus
│ ├── build_dataset.mjs # 2. 创建 LangSmith 数据集
│ ├── evaluators.mjs # 3. 定义三个评估器
│ ├── run_eval.mjs # 4. 批量跑评估
│ └── trigger-error.mjs # 5. 故意报错,验证 Trace 捕获能力
├── rag_agent.mjs # LangGraph 编排的 RAG 图
└── cli.mjs # 命令行入口
3.1 依赖与环境变量
npm i @langchain/core @langchain/openai @langchain/community \
@langchain/langgraph @langchain/textsplitters \
langchain langsmith openevals dotenv \
@zilliz/milvus2-sdk-node
.env 三组配置,注意变量名要全局统一(后文踩坑会讲):
# ── LLM(示例:阿里云百炼 DashScope 的 OpenAI 兼容模式)──
OPENAI_API_KEY=sk-xxxxxxxx
OPENAI_API_BASE_URL=https://dashscope.aliyuncs.com/compatible-mode/v1
MODEL_NAME=qwen-plus
EMBEDDING_MODEL=text-embedding-v3
# ── Milvus ──
MILVUS_URI=http://localhost:19530
MILVUS_COLLECTION=rag_docs
# ── LangSmith(开启后,Trace 自动上报)──
LANGCHAIN_TRACING_V2=true
LANGCHAIN_API_KEY=lsv2_pt_xxxxxxxx
LANGCHAIN_PROJECT=rag-observability
小知识:新版本的变量名已改名为
LANGSMITH_TRACING/LANGSMITH_API_KEY,旧的LANGCHAIN_*仍作为别名兼容。两者任选其一,不要同时写不同的值。
3.2 第一步:知识入库(Milvus)
RAG 的地基是向量库。这里用官方 SDK 手动建表,好处是字段结构完全可控,且能顺便理解 LangChain 向量存到底长什么样。
// scripts/milvus_insert.mjs
import "dotenv/config";
import { existsSync, readFileSync, readdirSync } from "fs";
import { join } from "path";
import { MilvusClient, DataType, IndexType, MetricType } from "@zilliz/milvus2-sdk-node";
import { RecursiveCharacterTextSplitter } from "@langchain/textsplitters";
import { OpenAIEmbeddings } from "@langchain/openai";
const COLLECTION = process.env.MILVUS_COLLECTION ?? "rag_docs";
// 关键:先给 URI 兜底,再做协议剥离。MilvusClient 内部会自动识别 https
const MILVUS_ADDRESS = (process.env.MILVUS_URI ?? "http://localhost:19530")
.replace(/^https?:\/\//, "");
const embeddings = new OpenAIEmbeddings({
apiKey: process.env.OPENAI_API_KEY,
model: process.env.EMBEDDING_MODEL ?? "text-embedding-v3",
configuration: {
baseURL: process.env.OPENAI_API_BASE_URL,
},
});
const client = new MilvusClient({ address: MILVUS_ADDRESS });
// ── 1. 读取文件 → 切分
async function loadChunks(dataDir = "./data") {
if (!existsSync(dataDir)) {
throw new Error(`Data directory ${dataDir} does not exist`);
}
const files = readdirSync(dataDir).filter((f) => /\.(txt|md)$/.test(f));
if (files.length === 0) {
throw new Error(`No .txt/.md files found in ${dataDir}`);
}
// Document → Chunk Document
const docs = files.map((f) => ({
pageContent: readFileSync(join(dataDir, f), "utf-8"),
metadata: { source: f },
}));
const splitter = new RecursiveCharacterTextSplitter({
chunkSize: 500,
chunkOverlap: 50,
});
return splitter.splitDocuments(docs);
}
async function main() {
try {
console.log("Connecting to Milvus ...");
await client.connectPromise;
console.log("Connected\n");
const chunks = await loadChunks();
// 幂等:存在则先删,避免脏数据
if ((await client.hasCollection({ collection_name: COLLECTION })).value) {
await client.dropCollection({ collection_name: COLLECTION });
console.log(`Collection ${COLLECTION} dropped \n`);
}
// ── 2. 向量化。注意这里用的是 embedDocuments(批量)
const vectors = await embeddings.embedDocuments(chunks.map((c) => c.pageContent));
const dim = vectors[0].length; // 维度从实际向量推断,而不是写死
// ── 3. 建表:主键 + 向量 + 原文 + 来源
await client.createCollection({
collection_name: COLLECTION,
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 },
],
});
console.log("Collection created");
// ── 4. 建索引 + 加载
await client.createIndex({
collection_name: COLLECTION,
field_name: "langchain_vector",
index_type: IndexType.IVF_FLAT,
metric_type: MetricType.L2,
params: { nlist: 128 },
});
console.log("Index created");
await client.loadCollection({ collection_name: COLLECTION });
// ── 5. 插入
const data = chunks.map((chunk, i) => ({
langchain_text: chunk.pageContent,
source: chunk.metadata.source,
langchain_vector: vectors[i],
}));
const result = await client.insert({ collection_name: COLLECTION, data });
console.log(`Inserted ${result.insert_cnt} records \n`);
} catch (error) {
console.error(error.message);
process.exit(1);
}
}
main();
这段代码里有三个值得记住的设计点:
- 字段名刻意用了
langchain_primaryid/langchain_vector/langchain_text。 因为下面 LangGraph 侧用的是@langchain/community的Milvus封装,它默认就按这套命名去找字段——自己手动建表和 LangChain 对接时,这是一条"隐式契约"。 dim不写死,从vectors[0].length反推。 换 Embedding 模型(1024 维 ↔ 1536 维)时不用改代码。IVF_FLAT + L2。 若向量已归一化,L2 距离与余弦相似度是单调等价的;若要上大规模数据,可换成HNSW或IVF_SQ8。
3.3 第二步:用 LangGraph 编排 RAG 图
RAG 的本质是"检索 → 生成"两段流水线,用 LangGraph 表达最清晰:状态(State)+ 节点(Node)+ 边(Edge)。
// rag_agent.mjs
import "dotenv/config";
import { Annotation, END, START, StateGraph } from "@langchain/langgraph";
import { ChatPromptTemplate } from "@langchain/core/prompts";
import { StringOutputParser } from "@langchain/core/output_parsers";
import { RunnableSequence } from "@langchain/core/runnables";
import { ChatOpenAI, OpenAIEmbeddings } from "@langchain/openai";
import { Milvus } from "@langchain/community/vectorstores/milvus";
const embeddings = new OpenAIEmbeddings({
apiKey: process.env.OPENAI_API_KEY,
configuration: { baseURL: process.env.OPENAI_API_BASE_URL },
model: process.env.EMBEDDING_MODEL ?? "text-embedding-v3",
});
const llm = new ChatOpenAI({
apiKey: process.env.OPENAI_API_KEY,
configuration: { baseURL: process.env.OPENAI_API_BASE_URL },
model: process.env.MODEL_NAME ?? "qwen-plus",
temperature: 0, // 评估场景必须为 0,否则分数会抖动
});
// 连接已有的集合(不重新建表)
const vectorStore = await Milvus.fromExistingCollection(embeddings, {
collectionName: process.env.MILVUS_COLLECTION ?? "rag_docs",
url: process.env.MILVUS_URI ?? "http://localhost:19530",
});
const retriever = vectorStore.asRetriever({ k: 4 });
const prompt = ChatPromptTemplate.fromMessages([
[
"system",
`你是客服助手。仅根据下面[上下文]回答;上下文没有的信息请明确说不知道,不要编造。
上下文: {context}`,
],
["human", "{question}"],
]);
// 线性链:prompt → llm → 只取字符串
const chain = RunnableSequence.from([prompt, llm, new StringOutputParser()]);
// ── 图状态定义:一个字段就是一个"通道"
const GraphState = Annotation.Root({
question: Annotation,
context: Annotation,
answer: Annotation,
});
// ── 节点 1:检索
async function retrieve(state) {
const docs = await retriever.invoke(state.question);
return { context: docs };
}
// ── 节点 2:生成
async function generate(state) {
const contextText = state.context.map((d) => d.pageContent).join("\n\n");
const answer = await chain.invoke({
context: contextText,
question: state.question,
});
return { answer };
}
// ── 装配成图:START → retrieve → generate → END
const workflow = new StateGraph(GraphState)
.addNode("retrieve", retrieve)
.addNode("generate", generate)
.addEdge(START, "retrieve")
.addEdge("retrieve", "generate")
.addEdge("generate", END);
// 编译:设计图 → 可运行的 Agent
export const ragApp = workflow.compile();
// ── 对外统一出口:注意同时返回 answer 和 context
export async function ask(question) {
const result = await ragApp.invoke({ question });
return {
answer: result.answer,
context: result.context ?? [],
};
}
为什么要同时返回 context? 因为后面"忠实度(幻觉检测)"评估器需要拿检索到的原文去比对回答,答案要留着做证据。这是整个评估链路能闭环的关键——Agent 的输出不只有 answer,还要带上"证据链"。
3.4 第三步:命令行入口
// cli.mjs
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 i = 0; i < questions.length; i++) {
const question = questions[i];
console.log(`问题 ${i + 1}: ${question}`);
const { answer } = await ask(question);
console.log(answer);
console.log("-----------------");
}
node cli.mjs 满多少元包邮?
跑到这里,你其实已经免费获得了一个 Trace——只要 .env 里的 LANGCHAIN_TRACING_V2=true 生效,LangChain / LangGraph / LangSmith 是打通的,LangSmith 后端会自动记录这次执行。
四、Trace 是怎么"自动"接上的
很多人以为要手写埋点,其实不用。原理是:LangChain 的每个 Runnable(prompt、llm、retriever、parser)内部都会发出 Callback 事件;LangGraph 又把节点执行包装成了 Runnable;LangSmith 只是注册了一个全局 CallbackHandler,把这些事件按调用树汇聚上报。
所以只要满足两个条件,Trace 就是白送的:
- 环境变量里开启了 tracing;
- 代码用的是 LangChain / LangGraph 生态的组件(而不是裸 fetch 调 API)。
如果确实需要手写,可以用
LangSmith的traceable装饰器包裹自己的普通函数,也能出现在同一条链路里。
在 LangSmith 的 Trace 页面,你能看到:
- 调用树:
ragApp→retrieve→Milvus.similaritySearch→generate→ChatOpenAI→StringOutputParser - 每个节点的耗时:一眼看出瓶颈是检索还是推理
- Token 统计:
prompt_tokens/completion_tokens/total_tokens与费用 - 每一步的输入输出:Prompt 拼了什么、检索回了哪几段、模型答了什么
这就是从"盲盒"到"仪表盘"的那一步。
五、异常验证:图报错也能被完整捕获
这里有个常识很多人在第一次用时才会踩到:LangGraph 的节点抛异常,整个 invoke 会直接 reject。所以我们故意写一个会报错的节点,验证两件事——异常能被业务捕获,也能在 Trace 里被定位。
// scripts/trigger-error.mjs
import "dotenv/config";
// 只要 .env 配好 LangSmith,这里的一切都会自动 trace
import { Annotation, END, START, StateGraph } from "@langchain/langgraph";
const StateAnnotation = Annotation.Root({
text: Annotation({
reducer: (_prev, next) => next,
default: () => "",
}),
});
// 正常节点
const stepOk = (state) => ({ text: `${state.text}[ok]` });
// 故意跑错的节点:抛异常,没有返回值
const stepThrow = () => {
throw new Error("DemoError:节点内故意抛错 (trigger-error.mjs)");
};
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 (err) {
console.error("已捕获:", err?.message ?? err);
process.exitCode = 1;
}
运行结果:控制台打印 已捕获:DemoError:...,同时 LangSmith 上这条 trace 会被标记为 Error,step_throw 节点整块变红,并且能看到:
- 该节点的入参 state(
{ text: "start[ok]" })——说明前一个节点确实跑通了; - 抛出的 异常堆栈;
- 链路上已完成节点的耗时——出问题前花了多久。
这里还有一个容易忽略的点:
Annotation支持自定义reducer与default。上面用的是"后写覆盖先写"的默认策略;若要累加(比如收集消息列表),就写成(prev, next) => prev.concat(next)。
六、Dataset:把"标准答案"沉淀成可复用资产
评估的第一难点不是"打分",而是**"拿什么题目打"**。
LangSmith 的 Dataset 就是干这个的:一批 inputs(用户提问)+ outputs(标准答案),统一存放、可复用、可回归。
// scripts/build_dataset.mjs
import "dotenv/config";
import { Client } from "langsmith";
const DATASET_NAME = "rag-eval-v1";
// 问题 - 标准答案 组合
const EXAMPLES = [
{
inputs: { question: "无理由退货要在几天内申请?" },
outputs: { answer: "自签收之日起 7 天内支持无理由退货。" },
},
{
inputs: { question: "质量问题换货期限是多久?" },
outputs: { answer: "15 天内出现质量问题可免费换货。" },
},
{
inputs: { question: "无理由退货运费谁承担?" },
outputs: { answer: "无理由退货由买家承担退货运费。" },
},
{
inputs: { question: "客服工作时间是什么?" },
outputs: { answer: "周一至周五 9:00-18:00,周六 10:00-17:00,法定节假日顺延。" },
},
{
inputs: { question: "满多少元包邮?" },
outputs: { answer: "满 99 元包邮(部分大件/冷链除外)。" },
},
{
inputs: { question: "现货商品多久发货?" },
outputs: { answer: "付款后 24 小时内发货,大促期间 48 小时内。" },
},
{
inputs: { question: "支持哪些支付方式?" },
outputs: {
answer: "支持微信支付、支付宝、银联云闪付、花呗/信用卡分期(满 500 元可选 3/6/12 期)。",
},
},
{
inputs: { question: "价保是多久?" },
outputs: { answer: "下单后 7 天内同款降价可申请差价退还。" },
},
{
inputs: { question: "金卡会员有什么折扣?" },
outputs: { answer: "金卡享 95 折,并有专属客服和每月满 200 减 30 券。" },
},
{
inputs: { question: "积分多少可以抵 1 元?" },
outputs: { answer: "100 积分可抵 1 元,单笔最多抵扣实付金额的 30%。" },
},
{
inputs: { question: "手机保修多久?" },
outputs: { answer: "手机、平板、耳机全国联保 1 年。" },
},
{
inputs: { question: "紧急问题怎么联系?" },
outputs: { answer: "可拨打 400-800-1234 转 2,接通后报订单号。" },
},
];
async function main() {
const client = new Client({ apiKey: process.env.LANGCHAIN_API_KEY });
let dataset;
try {
// 幂等:已存在就直接复用,避免重复建集
dataset = await client.readDataset({ datasetName: DATASET_NAME });
console.log("数据集已存在");
} catch (err) {
dataset = await client.createDataset(DATASET_NAME, {
description: "RAG Agent 回归评估集",
});
console.log("已创建数据集");
}
const created = await client.createExamples(
EXAMPLES.map((e) => ({
dataset_id: dataset.id,
inputs: e.inputs,
outputs: e.outputs,
}))
);
console.log(`已创建 ${created.length} 条样例`);
}
main().catch(console.error);
关于数据集的两条实战经验:
- 样例要从真实 Badcase 中来。 一开始可以人工造 10~20 条覆盖主流程;上线后把线上答错的 case 补进数据集,它就成了回归测试集。
outputs是"参考答案"而非"唯一答案"。 用 LLM-as-Judge 打分时,参考答案更多用于"答案正确性"类评估;本次的三个评估器则完全基于输入 + 检索结果 + 回答,不依赖参考答案。
七、Evaluators:用 LLM-as-Judge 量化 RAG 的三要素
7.1 为什么是这三个指标?
RAG 系统出错,只有三种可能,所以评估只需三把尺子:
flowchart TD
Q[用户提问] --> R[检索 Retrieve]
R --> G[生成 Generate]
G --> A[回答]
R -.->|尺子1: 检索相关性| M1[rag_retrieval_relevance]
R --> G
G -.->|尺子2: 忠实度/幻觉| M2[rag_groundedness]
G -.->|尺子3: 有用性| M3[rag_helpfulness]
| 指标 | 中文 | 回答的问题 | 适用场景 |
|---|---|---|---|
rag_retrieval_relevance | 检索相关性 | 召回的内容跟问题有关系吗? | 检索挂了 |
rag_groundedness | 忠实度 / 幻觉检测 | 回答是否完全基于检索上下文? | 生成了幻觉 |
rag_helpfulness | 有用性 | 回答是否真正解决了用户的问题? | 答案正确但没用 |
注意
rag_groundedness只判断"有没有依据",不判断依据本身对不对。所以它必须和"检索相关性"搭配看:如果检索回来的就是错的,模型"忠实"地抄了错答案,忠实度反而会很高——这恰恰是需要警惕的组合。
7.2 用 openevals 三行定义一个评估器
// scripts/evaluators.mjs
import {
createLLMAsJudge, // 把任意 LLM 变成"裁判"
RAG_GROUNDEDNESS_PROMPT, // 忠实度 / 幻觉检测提示词
RAG_HELPFULNESS_PROMPT, // 有用性提示词
RAG_RETRIEVAL_RELEVANCE_PROMPT, // 检索相关性提示词
} from "openevals";
import { ChatOpenAI } from "@langchain/openai";
const judge = new ChatOpenAI({
apiKey: process.env.OPENAI_API_KEY,
configuration: { baseURL: process.env.OPENAI_API_BASE_URL },
model: process.env.MODEL_NAME ?? "qwen-plus",
temperature: 0,
timeout: 120_000,
maxRetries: 2,
});
const ragGroundednessJudge = createLLMAsJudge({
prompt: RAG_GROUNDEDNESS_PROMPT,
feedbackKey: "rag_groundedness",
judge,
continuous: true, // 输出 0~1 连续分数,而非 pass/fail
});
const ragHelpfulnessJudge = createLLMAsJudge({
prompt: RAG_HELPFULNESS_PROMPT,
feedbackKey: "rag_helpfulness",
judge,
continuous: true,
});
const ragRetrievalRelevanceJudge = createLLMAsJudge({
prompt: RAG_RETRIEVAL_RELEVANCE_PROMPT,
feedbackKey: "rag_retrieval_relevance",
judge,
continuous: true,
});
// ── 1. 忠实度评估器:需要「检索上下文」+「回答」
export async function ragGroundednessEvaluator({ outputs }) {
return ragGroundednessJudge({
context: { documents: outputs.context },
outputs: { answer: outputs.answer },
});
}
// ── 2. 有用性评估器:需要「原始提问」+「回答」
export async function ragHelpfulnessEvaluator({ inputs, outputs }) {
return ragHelpfulnessJudge({
inputs,
outputs: { answer: outputs.answer },
});
}
// ── 3. 检索相关性评估器:需要「原始提问」+「检索上下文」
export async function ragRetrievalRelevanceEvaluator({ inputs, outputs }) {
return ragRetrievalRelevanceJudge({
inputs,
context: { documents: outputs.context },
});
}
export const ragEvaluators = [
ragGroundednessEvaluator,
ragHelpfulnessEvaluator,
ragRetrievalRelevanceEvaluator,
];
三个关键理解点:
- 评估器的入参是
{ inputs, outputs, referenceOutputs },分别对应"问题 / Agent 产出 / 数据集标准答案"。上面三个评估器只用了inputs和outputs——这正是ask()必须返回context的原因。 continuous: true返回 0~1 的连续分,便于算平均分做趋势对比;关掉则返回pass/fail二值结果。- 裁判模型可以和被测模型相同,也可以更强。 生产中通常用更强的模型当裁判,避免"自己判自己"的偏差;但注意裁判模型也会有偏见,不要用它做唯一的验收标准。
八、跑批评估:一条命令产出量化分数
最后一步,把 Agent、Dataset、Evaluators 三者接起来,批量跑:
// scripts/run_eval.mjs
import "dotenv/config";
import { Client } from "langsmith";
import { evaluate } from "langsmith/evaluation";
import { ask } from "../rag_agent.mjs";
import { ragEvaluators } from "./evaluators.mjs";
const DATASET_NAME = "rag-eval-v1";
const client = new Client({ apiKey: process.env.LANGCHAIN_API_KEY });
// ── 被测目标:把输入喂给 Agent,并归一化输出结构
async function runRagAgent(inputs) {
const { answer, context } = await ask(inputs.question);
return {
answer,
// 注意:Document 不能直接给裁判模型,要转成纯文本
context: context.map((d) => d.pageContent),
};
}
async function main() {
const result = await evaluate(runRagAgent, {
data: DATASET_NAME, // 数据集名
evaluators: ragEvaluators, // 评估器数组
client, // LangSmith Client
experimentPrefix: `rag-openevals-${process.env.MODEL_NAME ?? "qwen"}`, // 实验名前缀
maxConcurrency: 1, // 并发控制,防限流
});
console.log(result);
}
main().catch((err) => {
console.error(err);
process.exit(1);
});
node scripts/run_eval.mjs
跑完你会得到:
- LangSmith 上一份 实验(Experiment)报告,含三个指标的平均分与逐条明细;
- 数据集里每一条样本都有测得的三个分数 + 裁判给出的理由(reason);
- 页面上可一键对比多个实验(不同模型 / 不同 Prompt / 不同
k值)。
experimentPrefix里带了模型名,是为了让"换模型"这件事变成一次可对比的实验:跑qwen-plus和qwen-max会生成两组独立实验,直接在 LangSmith 上并排看分数差异。
九、结果解读:从"打分"到"优化闭环"
拿到分数只是开始,关键是怎么读。下面是一张排错决策表(强烈建议收藏):
| 检索相关性 | 忠实度 | 有用性 | 病因诊断 | 优化方向 |
|---|---|---|---|---|
| 低 | 高 | 低 | 检索不到相关文档 | 调 k、换 Embedding、改 chunkSize/chunkOverlap、加混合检索 |
| 低 | 高 | 高 | 检索差但模型靠常识补上了 | 危险信号,属于"侥幸答对",要修检索 |
| 高 | 低 | 高 | 典型幻觉:检索对了却自己编 | 收紧 System Prompt("仅根据上下文回答")、降 temperature |
| 高 | 高 | 低 | 检索对、没瞎编,但答非所问 | 改 Prompt 结构、补 few-shot、检查上下文拼接顺序 |
| 高 | 高 | 高 | 健康 | 可以灰度放量了 |
优化闭环的正确姿势:
- 先跑一次基线(记录三个分数的平均值),不要凭感觉改;
- 一次只改一个变量(
k、chunkSize、Prompt 措辞、模型),再跑实验; - 用 LangSmith 的实验对比看分数涨跌,涨了才合入;
- 把线上 Badcase 补进 Dataset,数据集越用越准。
Prompt / 参数 / 模型三者中,只有模型和 k 能显著改变分数曲线,Prompt 改措辞往往只影响 2~3 个百分点——所以务必先量后改。
十、速查表(收藏用)
文件与职责
| 文件 | 职责 |
|---|---|
milvus_insert.mjs | 文档切分 → 向量化 → 建表建索引 → 写入 Milvus |
rag_agent.mjs | LangGraph 编排 retrieve → generate,导出 ask() |
cli.mjs | 命令行单问题调试入口 |
trigger-error.mjs | 故意抛错,验证 Trace 的异常捕获能力 |
build_dataset.mjs | 建/复用 LangSmith Dataset + 写入样例 |
evaluators.mjs | 定义三个 LLM-as-Judge 评估器 |
run_eval.mjs | evaluate() 批量跑分,生成实验报告 |
核心 API 速记
| 目标 | API |
|---|---|
| 定义图状态 | Annotation.Root({ ... }) |
| 自定义字段归并 | Annotation({ reducer, default }) |
| 装配并编译图 | new StateGraph(State).addNode(...).addEdge(...).compile() |
| 连接已有向量集合 | Milvus.fromExistingCollection(embeddings, dbConfig) |
| 转成检索器 | vectorStore.asRetriever({ k: 4 }) |
| 建数据集 | client.createDataset(name, { description }) |
| 写样例 | client.createExamples([{ dataset_id, inputs, outputs }]) |
| 造裁判 | createLLMAsJudge({ prompt, feedbackKey, judge, continuous }) |
| 批量评估 | evaluate(targetFn, { data, evaluators, client, experimentPrefix }) |
一句话总结
Trace 解决"看不见",Dataset 解决"没标准",Evaluators 解决"说不清",evaluate 解决"批量比"。四者合起来,就是把 Agent 从"盲盒"变成"工程"。
写在最后
Agent 开发的成熟度,取决于你能不能在没有肉眼观察的情况下,依然对自己的系统有信心。
LangSmith 提供的这套"追踪 → 数据集 → 评估 → 实验对比"链路,本质上是在给 LLM 应用补上传统软件工程早就有的东西:日志、单元测试、指标。区别只是——这里的"正确答案"需要用 LLM 来判,而判得准不准,本身也是一门工程。
先把 Trace 用起来,再把 Dataset 攒起来,最后让 evaluate 每天帮你跑一次。你会发现,优化 Agent 从"玄学"变成了"看数"。
如果这篇文章帮你把盲盒拆开了,欢迎点赞收藏,方便日后回看这份速查表。