中文资讯 掘金 AI Sun, 18 Oct 2026 正文 ok 2644 words

告别“盲盒式”调试 Agent:LangSmith 全链路观测与 RAG 量化评估实战(LangGraph × Milvus)

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

本文通过一个基于 LangGraph 和 Milvus 的 RAG 项目,介绍如何利用 LangSmith 追踪 Agent 调用链,并结合数据集和评估器量化回答质量。项目展示了知识入库、检索与生成节点编排、命令行调用,以及通过 Trace 定位耗时、Token 消耗和异常的方法。文章还提供了构建问答数据集的代码,强调将检索上下文作为证据返回,以支持后续忠实度评估。原文在数据集代码中途截断,未提供完整的评估器和批量实验实现。
打开原文相关 6 · 机会 2
media3.2 KB media589 B media4.8 KB

中文

告别“盲盒式”调试 Agent:LangSmith 全链路观测与 RAG 量化评估实战(LangGraph × Milvus)

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

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

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


目录


一、先搞清楚:没有观测的 Agent 有多难维护

传统后端服务出问题,你有日志、有链路追踪、有 Prometheus 指标。而 Agent 出问题时,你往往只有一句“它答得不对”。

具体来说,Agent 的“黑盒”体现在四个层面:

层面 你会遇到的困惑 需要回答的问题
调用链 一个问题背后运行了哪些节点? 是 retrieve 出错了,还是 generate 出错了?
性能 用户等了 8 秒,时间花在哪? 检索慢,还是 LLM 首 Token 慢?
成本 这个月 Token 花了多少钱? 是 Prompt 太长,还是检索塞了太多文档?
质量 回答“看起来对”,真的对吗? 是幻觉、答非所问,还是根本没检索到?

**没有客观测量,就没有优化的方向。**你只能靠“改一版 Prompt 试试看”这样的玄学迭代,而每次改动是否真的变好,无人知晓。

这就是 LangSmith 存在的意义:它不是可选项,而是 Agent 工程的仪表盘。


二、LangSmith 的四大核心能力

先用一张表记住它们,后面都会用到:

能力 作用 解决什么问题
Tracing(追踪) 记录每次 Agent 执行的完整调用树 调试 bug、定位出错的节点或工具
Monitoring(监控) 在后台实时查看延迟、Token 开销和工具调用 上线后的性能与成本观测
Datasets(数据集) 统一存放“用户提问 + 标准答案” 建立可回归的测试样本集
Evaluators(评估器) 对 Agent 的回答自动打分 批量自动化评测,量化回答质量

一句话总结工作流:

mermaid 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

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

text rag-observability/ ├── .env ├── data/ ├── scripts/ │ ├── milvus_insert.mjs │ ├── build_dataset.mjs │ ├── evaluators.mjs │ ├── run_eval.mjs │ └── trigger-error.mjs ├── rag_agent.mjs └── 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 中有三组配置,注意变量名要全局统一(后文会讲到相关问题):

dotenv 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_URI=http://localhost:19530 MILVUS_COLLECTION=rag_docs

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 向量存储的结构。

javascript 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";

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 });

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}); }

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`);
}


const vectors = await embeddings.embedDocuments(chunks.map((c) => c.pageContent));
const dim = vectors[0].length; 


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");


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 });


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)。

javascript 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, });

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}"], ]);

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

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

async function retrieve(state) { const docs = await retriever.invoke(state.question); return { context: docs }; }

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 }; }

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();

export async function ask(question) { const result = await ragApp.invoke({ question }); return { answer: result.answer, context: result.context ?? [], }; }

为什么要同时返回 context?因为后面的“忠实度(幻觉检测)”评估器需要拿检索到的原文与回答进行比对,留下证据。这是整个评估链路能够闭环的关键——Agent 的输出不只有 answer,还要带上“证据链”。

3.4 第三步:命令行入口

javascript 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 里被定位。

javascript import "dotenv/config";

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

javascript 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: "手

(原文在此处截断,代码不完整。)

English / 原文

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

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

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

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


目录


一、先搞清楚:没有观测的 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/                    
├── scripts/
│   ├── milvus_insert.mjs    
│   ├── build_dataset.mjs    
│   ├── evaluators.mjs       
│   ├── run_eval.mjs         
│   └── trigger-error.mjs    
├── rag_agent.mjs            
└── 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 三组配置,注意变量名要全局统一 (后文踩坑会讲):

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_URI=http://localhost:19530
MILVUS_COLLECTION=rag_docs


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 向量存到底长什么样。

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";


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 });


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}`);
  }

  
  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`);
    }

    
    const vectors = await embeddings.embedDocuments(chunks.map((c) => c.pageContent));
    const dim = vectors[0].length; 

    
    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");

    
    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 });

    
    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) 。

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, 
});


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}"],
]);


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


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


async function retrieve(state) {
  const docs = await retriever.invoke(state.question);
  return { context: docs };
}


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 };
}


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();


export async function ask(question) {
  const result = await ragApp.invoke({ question });
  return {
    answer: result.answer,
    context: result.context ?? [],
  };
}

为什么要同时返回context? 因为后面"忠实度(幻觉检测)"评估器需要拿检索到的原文 去比对回答,答案要留着做证据 。这是整个评估链路能闭环的关键——Agent 的输出不只有 answer,还要带上"证据链" 。

3.4 第三步:命令行入口

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 就是白送的:

  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 里被定位。

import "dotenv/config";

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

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 三行定义一个评估器

import {
  createLLMAsJudge,               
  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, 
});

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,
});


export async function ragGroundednessEvaluator({ outputs }) {
  return ragGroundednessJudge({
    context: { documents: outputs.context },
    outputs: { answer: outputs.answer },
  });
}


export async function ragHelpfulnessEvaluator({ inputs, outputs }) {
  return ragHelpfulnessJudge({
    inputs,
    outputs: { answer: outputs.answer },
  });
}


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 三者接起来,批量跑:

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 });


async function runRagAgent(inputs) {
  const { answer, context } = await ask(inputs.question);
  return {
    answer,
    
    context: context.map((d) => d.pageContent),
  };
}

async function main() {
  const result = await evaluate(runRagAgent, {
    data: DATASET_NAME,                                                   
    evaluators: ragEvaluators,                                            
    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、检查上下文拼接顺序
高 高 高 健康 可以灰度放量了

优化闭环的正确姿势:

  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 从"玄学"变成了"看数"。

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