别再"盲盒式"调 Agent 了!LangSmith 全链路观测 + RAG 量化评估实战(LangGraph × Milvus)

别再"盲盒式"调 Agent 了!LangSmith 全链路观测 + RAG 量化评估实战(LangGraph × Milvus)

如果你无法度量它,你就无法管理它。

用 LangChain / LangGraph 写 Agent,最大的痛苦不是写不出来,而是写出来之后那种强烈的**"盲盒感"**:调用了哪个工具?每一步耗时多少?流式输出的 Token 消耗了多少?回答到底是"真懂"还是"瞎编"?

这篇文章用一个完整的 RAG 项目,把 LangSmith 从 Trace 追踪 → Dataset 数据集 → LLM-as-Judge 评估器 → 批量实验 的全链路串起来,帮你把"盲盒"变成一块随时能看的仪表盘。


目录

  • [一、先搞清楚:没有观测的 Agent 有多难维护](#一、先搞清楚:没有观测的 Agent 有多难维护 "#%E4%B8%80%E5%85%88%E6%90%9E%E6%B8%85%E6%A5%9A%E6%B2%A1%E6%9C%89%E8%A7%82%E6%B5%8B%E7%9A%84-agent-%E6%9C%89%E5%A4%9A%E9%9A%BE%E7%BB%B4%E6%8A%A4")
  • [二、LangSmith 的四大核心能力](#二、LangSmith 的四大核心能力 "#%E4%BA%8Clangsmith-%E7%9A%84%E5%9B%9B%E5%A4%A7%E6%A0%B8%E5%BF%83%E8%83%BD%E5%8A%9B")
  • [三、动手:搭一个"可被观测"的 RAG Agent](#三、动手:搭一个"可被观测"的 RAG Agent "#%E4%B8%89%E5%8A%A8%E6%89%8B%E6%90%AD%E4%B8%80%E4%B8%AA%E5%8F%AF%E8%A2%AB%E8%A7%82%E6%B5%8B%E7%9A%84-rag-agent")
  • [四、Trace 是怎么"自动"接上的](#四、Trace 是怎么"自动"接上的 "#%E5%9B%9Btrace-%E6%98%AF%E6%80%8E%E4%B9%88%E8%87%AA%E5%8A%A8%E6%8E%A5%E4%B8%8A%E7%9A%84")
  • 五、异常验证:图报错也能被完整捕获
  • 六、Dataset:把"标准答案"沉淀成可复用资产
  • [七、Evaluators:用 LLM-as-Judge 量化 RAG 的三要素](#七、Evaluators:用 LLM-as-Judge 量化 RAG 的三要素 "#%E4%B8%83evaluators%E7%94%A8-llm-as-judge-%E9%87%8F%E5%8C%96-rag-%E7%9A%84%E4%B8%89%E8%A6%81%E7%B4%A0")
  • 八、跑批评估:一条命令产出量化分数
  • 九、结果解读:从"打分"到"优化闭环"
  • 十、速查表(收藏用)

一、先搞清楚:没有观测的 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

整个项目的文件结构如下:

bash 复制代码
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 依赖与环境变量

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

.env 三组配置,注意变量名要全局统一(后文踩坑会讲):

bash 复制代码
# ── 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 向量存到底长什么样。

js 复制代码
// 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();

这段代码里有三个值得记住的设计点:

  1. 字段名刻意用了 langchain_primaryid / langchain_vector / langchain_text。 因为下面 LangGraph 侧用的是 @langchain/community 的 Milvus 封装,它默认就按这套命名去找字段------自己手动建表和 LangChain 对接时,这是一条"隐式契约"。
  2. dim 不写死,从 vectors[0].length 反推。 换 Embedding 模型(1024 维 ↔ 1536 维)时不用改代码。
  3. IVF_FLAT + L2。 若向量已归一化,L2 距离与余弦相似度是单调等价的;若要上大规模数据,可换成 HNSW 或 IVF_SQ8。

3.3 第二步:用 LangGraph 编排 RAG 图

RAG 的本质是"检索 → 生成"两段流水线,用 LangGraph 表达最清晰:状态(State)+ 节点(Node)+ 边(Edge)。

js 复制代码
// 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 第三步:命令行入口

js 复制代码
// 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("-----------------");
}
bash 复制代码
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 就是白送的:

  1. 环境变量里开启了 tracing;
  2. 代码用的是 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 里被定位。

js 复制代码
// 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(标准答案),统一存放、可复用、可回归。

js 复制代码
// 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 三行定义一个评估器

js 复制代码
// 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,
];

三个关键理解点:

  1. 评估器的入参是 { inputs, outputs, referenceOutputs } ,分别对应"问题 / Agent 产出 / 数据集标准答案"。上面三个评估器只用了 inputs 和 outputs------这正是 ask() 必须返回 context 的原因。
  2. continuous: true 返回 0~1 的连续分 ,便于算平均分做趋势对比;关掉则返回 pass/fail 二值结果。
  3. 裁判模型可以和被测模型相同,也可以更强。 生产中通常用更强的模型当裁判,避免"自己判自己"的偏差;但注意裁判模型也会有偏见,不要用它做唯一的验收标准。

八、跑批评估:一条命令产出量化分数

最后一步,把 Agent、Dataset、Evaluators 三者接起来,批量跑:

js 复制代码
// 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);
});
bash 复制代码
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、检查上下文拼接顺序
高 高 高 健康 可以灰度放量了

优化闭环的正确姿势:

  1. 先跑一次基线(记录三个分数的平均值),不要凭感觉改;
  2. 一次只改一个变量 (k、chunkSize、Prompt 措辞、模型),再跑实验;
  3. 用 LangSmith 的实验对比看分数涨跌,涨了才合入;
  4. 把线上 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 从"玄学"变成了"看数"。

如果这篇文章帮你把盲盒拆开了,欢迎点赞收藏,方便日后回看这份速查表。

相关推荐
李溪白2 小时前
RAG 进阶:Agentic RAG 与多智能体协作(系列二·第3篇):多模态 Agent
agent
方方洛2 小时前
ai-agent教程-14-附录A-术语表
人工智能·llm·agent
Morning的Tech2 小时前
LangGraph 的 Swarm 多智能体机制
人工智能·agent
用户3134672143542 小时前
learn-claude-code笔记4: Hooks — 挂在循环上,不写进循环里
llm·agent
simon_1233 小时前
不到一个月,agent 的权限分类器拦了我 27 次:至少 7 次只是在"看"
agent
lucas_AI3 小时前
Agent 工具箱越大越难选?Tracer 砍掉八成工具 schema,选得反而更准
llm·agent
学IT的周星星3 小时前
记忆系统:让 Agent 记住你是谁、上次聊到哪
运维·服务器·人工智能·windows·大模型·llm·agent
yuanyxh3 小时前
我的工作流
agent·claude·deepseek
badhope4 小时前
AI Agent安全治理实战:从API密钥泄露到沙箱隔离的完整防线
后端·llm·agent