LangSmith 全链路观测:从 Agent 调试到 RAG 量化评估

做 AI Agent 或 RAG 应用,最容易被低估的问题不是"怎么把模型调通",而是"调通以后怎么持续知道它为什么这样回答"。

一个终端里能不断打印 token 的 demo,离可上线的 AI 应用还有很长距离。真实业务里,用户问一句"紧急问题怎么联系客服?",系统背后可能经历了问题改写、Embedding、向量检索、上下文拼接、Prompt 渲染、模型生成、输出解析、工具调用、重试和异常处理。最终答案如果错了,不能只说"模型幻觉了"。工程上真正要追问的是:

  • 检索阶段有没有召回正确文档?
  • 召回到了正确文档,为什么生成阶段没有引用?
  • Prompt 里有没有把约束讲清楚?
  • LLM 调用耗时和 token 成本是否异常?
  • 改了 chunk size、topK、模型或提示词后,整体效果到底变好还是变差?

这就是 LangSmith 的价值。它不是给 LangChain / LangGraph 应用"加一个漂亮后台",而是把 Agent 和 RAG 从一次性演示推进到可调试、可回归、可量化优化的工程系统。

本文会基于一个本地项目 langsmith-test 来拆解完整链路:用 Milvus 存储电商客服知识,用 LangGraph 编排一个简单 RAG Agent,再用 LangSmith 做运行 Trace、Dataset、Evaluator 和 Experiment。文章里的代码来自当前项目实现,并在不改变核心含义的前提下补充中文注释,方便理解每段代码在系统里的位置。

先说结论:LangSmith 解决的是 AI 应用的工程反馈闭环

很多人第一次接入 LangSmith,只会关注 Trace 页面能不能看到每一步输入输出。Trace 很重要,但它只是第一层价值。

一个工程化 AI 系统至少需要两类反馈:

第一类是单次请求的运行反馈。比如一次 RAG 问答为什么慢、为什么错、检索到了什么、模型到底看到了什么上下文、哪一步抛了异常。这类问题依赖 Trace。

第二类是版本变化后的效果反馈。比如把 k=4 改成 k=8,把 chunkSize=500 改成 800,把 qwen-plus 换成另一个模型,或者把 system prompt 写得更严格以后,整体业务质量有没有提升。这类问题不能靠肉眼看几条日志,必须依赖 Dataset、Evaluator 和 Experiment。

所以 LangSmith 的核心主线不是"记录日志",而是:

text 复制代码
把每一次 AI 调用拆成可观察的运行单元,再把一批标准样本变成可重复的评测实验。

这也是 Agent 和 RAG 工程化的分水岭。没有 LangSmith 这类观测和评估层时,团队讨论问题往往停留在"感觉效果还行""这次回答挺准""昨天好像有个 case 错了"。接入以后,讨论可以变成"这个版本 retrieval relevance 从 0.72 降到 0.61,问题主要集中在售后类样本,Trace 显示召回片段来源偏向 membership.md,先调切分和召回策略"。

这才是可优化的工程对象。

为什么 Agent / RAG 比传统接口更需要观测

传统后端接口通常有明确的输入、确定性的业务逻辑和相对稳定的输出。你排查问题时,可以看日志、查数据库、重放请求,大多数时候能沿着代码路径定位。

LLM 应用不一样,它有几个天然特点:

第一,核心行为具有概率性。即使 temperature 设为 0,不同模型版本、上下文顺序、工具返回格式,也可能影响最终表达。

第二,链路中间态非常重要。RAG 不是一个"问题进、答案出"的黑盒。检索结果、上下文拼接方式、Prompt 模板、模型调用参数,都会决定结果。如果只保存最终答案,很多问题根本无法复盘。

第三,Agent 会引入更多非确定分支。它可能选择不同工具,循环多轮调用,或者在某个节点失败后进入重试。最终答案错了,错误可能来自工具选择、工具参数、外部 API、上下文压缩,也可能来自模型推理。

第四,质量不是单一指标。RAG 的好坏不是"答对 / 答错"这么简单。一个回答可能很有用,但没有被上下文支撑;也可能完全忠实于上下文,但没有回答用户真正的问题;还可能最终答案没问题,但检索召回其实很差,只是模型靠常识蒙对了。

这就是为什么在 AI 应用里,日志只能解决一部分问题。你需要的是结构化的 Trace 和可重复的 Evaluation。

LangSmith 在链路中的位置

本文的示例项目做的是一个电商客服知识库 RAG。数据来自本地 data 目录里的几份文档:配送物流、支付发票、会员积分、商品质保和售后服务。整体流程可以拆成两条线:

一条是离线入库链路:把文档切成 chunk,调用 Embedding 模型生成向量,写入 Milvus。

另一条是在线问答链路:用户问题进入 LangGraph,先检索 Milvus,再把召回上下文交给 LLM 生成答案。

LangSmith 横跨在线运行和离线评测:在线时记录每次 Graph、Retriever、LLM 的运行;评测时用 Dataset 批量驱动同一个 RAG Agent,再用 Evaluator 生成分数,形成 Experiment。

flowchart LR A[本地客服文档] --> B[Text Splitter] B --> C[Embedding Model] C --> D[Milvus Vector Store] U[用户问题] --> G[LangGraph RAG Agent] G --> R[retrieve 节点] R --> D D --> R R --> P[Prompt 拼接] P --> L[LLM 生成] L --> O[答案与引用片段] G -.运行输入输出.-> T[LangSmith Trace] R -.检索片段.-> T L -.Prompt / Token / Latency.-> T DS[LangSmith Dataset] --> EV[evaluate] EV --> G EV --> J[OpenEvals Evaluators] J --> EX[LangSmith Experiment]

如果只看业务代码,LangSmith 好像不是必须的;如果从工程闭环看,它是把"运行过程"和"效果评估"连接起来的那层基础设施。

环境变量:新推荐写法与兼容写法

当前 LangSmith 官方文档推荐使用 LANGSMITH_* 环境变量,例如:

bash 复制代码
# 开启 LangSmith tracing,上线环境建议按项目单独配置
LANGSMITH_TRACING=true

# LangSmith API Key,用于把 trace 和评测结果上报到 LangSmith
LANGSMITH_API_KEY=lsv2_xxx

# 指定 LangSmith 项目名,便于按应用、环境或版本隔离观察数据
LANGSMITH_PROJECT=langsmith-test

本地项目的 .env 使用的是旧一代 LangChain 命名:

bash 复制代码
# 兼容写法:当前项目源码使用这组变量
LANGCHAIN_TRACING_V2=true
LANGCHAIN_API_KEY=lsv2_xxx
LANGCHAIN_PROJECT=langsmith-test

这两组变量在生态里有历史兼容关系。写博客或新项目时,我更建议优先写 LANGSMITH_*,因为命名更直接,和 LangSmith 产品本身一致;如果你维护的是旧 LangChain 项目,看到 LANGCHAIN_TRACING_V2 也不用惊讶,它表达的是同一类 tracing 开关。

除了 LangSmith,示例项目还需要 LLM、Embedding 和 Milvus 配置:

bash 复制代码
# 模型服务配置:这里使用 OpenAI 兼容接口
OPENAI_API_KEY=sk-xxx
OPENAI_BASE_URL=https://dashscope.aliyuncs.com/compatible-mode/v1
MODEL_NAME=qwen-plus

# Embedding 模型:用于把文档 chunk 和用户问题映射到向量空间
EMBEDDING_MODEL=text-embedding-v3

# Milvus 连接信息:用于向量存储和相似度检索
MILVUS_URI=http://localhost:19530
MILVUS_COLLECTION=rag_docs

这里有一个工程习惯值得保留:模型、向量库、collection 名称都从环境变量读取,不写死在业务代码里。这样做的直接收益是评测不同版本时不用改代码,只需要切换环境变量或启动参数。

离线入库:RAG 的质量从文档切分开始

很多 RAG 效果问题,最后追到根因都不是模型不行,而是入库阶段就埋了坑。比如 chunk 太大,检索结果噪声高;chunk 太小,答案需要的上下文被切碎;metadata 没保留,后续无法知道答案来自哪个文档;向量字段名和 LangChain 约定不一致,检索层读不到数据。

当前项目的入库脚本是 src/milvus_insert.mjs。它做了几件事:

  • 读取 data 目录下的 .txt.md 文件。
  • RecursiveCharacterTextSplitter 切分文档。
  • 调用 OpenAIEmbeddings 生成向量。
  • 创建 Milvus collection 和向量索引。
  • 写入 chunk 文本、向量和来源文件。

下面是整理后的关键代码,中文注释保留了工程意图:

js 复制代码
// 加载 .env,让模型、Milvus、LangSmith 配置都可以从环境变量读取
import dotenv from "dotenv"
dotenv.config({ override: true })

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"

// collection 名称不要写死,便于按环境或实验版本隔离数据
const COLLECTION = process.env.MILVUS_COLLECTION ?? "rag_docs"

// Milvus SDK 需要 host:port,这里把 http:// 前缀去掉
const MILVUS_ADDRESS =
  process.env.MILVUS_URI?.replace(/^https?:\/\//, "") ?? "localhost:19530"

// 初始化 Embedding 模型;baseURL 让代码可以接入 OpenAI 兼容服务
const embeddings = new OpenAIEmbeddings({
  apiKey: process.env.OPENAI_API_KEY,
  model: process.env.EMBEDDING_MODEL ?? "text-embedding-v3",
  configuration: { baseURL: process.env.OPENAI_BASE_URL },
})

// MilvusClient 负责 collection、index 和 insert 这些底层操作
const client = new MilvusClient({ address: MILVUS_ADDRESS })

这段代码看起来只是初始化,但它决定了后续能不能稳定复现实验。如果 collection 名称固定写死,A 版本和 B 版本的评测数据就可能混在一起;如果 embedding 模型不可配置,切换模型时就很难做横向对比;如果 baseURL 不暴露出来,使用兼容模型服务时又要改代码。

文档切分是入库链路里最值得解释的部分:

js 复制代码
async function loadChunks(dataDir = "./data") {
  // 提前检查数据目录,避免后面出现难定位的文件读取错误
  if (!existsSync(dataDir)) {
    throw new Error(`数据目录不存在: ${dataDir}`)
  }

  // 示例只处理文本和 Markdown,避免把图片、临时文件误入库
  const files = readdirSync(dataDir).filter(f => /\.(txt|md)$/i.test(f))
  if (files.length === 0) {
    throw new Error(`目录内无 .txt/.md 文件: ${dataDir}`)
  }

  // 转成 LangChain Document 结构,同时保留 source 作为可追溯元数据
  const docs = files.map(f => ({
    pageContent: readFileSync(join(dataDir, f), "utf-8"),
    metadata: { source: f },
  }))

  // chunkSize 控制单个片段大小,chunkOverlap 保留跨片段上下文连续性
  const splitter = new RecursiveCharacterTextSplitter({
    chunkSize: 500,
    chunkOverlap: 50,
  })

  return splitter.splitDocuments(docs)
}

chunkSize=500chunkOverlap=50 不是万能默认值,但很适合这个示例:文档是客服政策类,单条规则通常不长,500 字能容纳完整语义,50 字重叠可以降低规则被切断的概率。

如果换成合同、法律条款或研发文档,这个参数就要重新评估。chunk 太大会让检索结果夹带大量无关信息,影响 LLM 注意力;chunk 太小则容易出现"召回了半句话"的问题,生成阶段不得不靠模型补全,幻觉风险会上升。

创建 collection 时,字段名也不是随便起的:

js 复制代码
await client.createCollection({
  collection_name: COLLECTION,
  fields: [
    {
      // 主键使用 Milvus 自动生成,避免业务侧手动维护 ID
      name: "langchain_primaryid",
      data_type: DataType.Int64,
      is_primary_key: true,
      autoID: true,
    },
    {
      // 向量字段名与 LangChain Milvus 默认约定保持一致
      name: "langchain_vector",
      data_type: DataType.FloatVector,
      dim,
    },
    {
      // 文本字段保存 chunk 原文,检索后要交给 LLM 作为上下文
      name: "langchain_text",
      data_type: DataType.VarChar,
      max_length: 8000,
    },
    {
      // source 用于追踪答案来自哪个文件,排查召回问题时很关键
      name: "source",
      data_type: DataType.VarChar,
      max_length: 256,
    },
  ],
})

这里最容易踩的坑是字段名。后面的在线 RAG 使用 @langchain/community/vectorstores/milvus 从已有 collection 读取,如果底层字段不符合 LangChain Milvus 的默认约定,就会出现数据写进去了、检索层却读不到或读错字段的问题。

索引部分使用的是 IVF_FLAT + L2

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

这对 demo 和中小规模验证足够直观。nlist 可以理解为把向量空间分成多少个聚类桶,值太小召回粗糙,值太大索引构建和查询成本会上升。真实业务里你还要结合数据规模、延迟目标、召回率要求选择 HNSW、IVF_FLAT 或其他索引,并且最好把索引参数纳入评测对比,而不是凭感觉调。

在线链路:用 LangGraph 把 RAG 拆成可观察节点

项目里的 src/rag_agent.mjs 没有把 RAG 写成一个长函数,而是用 LangGraph 拆成 retrievegenerate 两个节点。这是一个很好的示例,因为 LangSmith 记录 Trace 时,节点边界越清晰,排查越容易。

先看模型、向量库和 Retriever 的初始化:

js 复制代码
// 加载环境变量,确保模型、向量库和 LangSmith tracing 配置生效
import dotenv from "dotenv"
dotenv.config({ override: true })

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"

// Embedding 用于把用户问题转成向量,必须和入库时的模型保持一致
const embeddings = new OpenAIEmbeddings({
  apiKey: process.env.OPENAI_API_KEY,
  configuration: { baseURL: process.env.OPENAI_BASE_URL },
  model: process.env.EMBEDDING_MODEL ?? "text-embedding-v3",
})

// 生成模型 temperature 设为 0,评测时降低随机性,便于复现对比
const llm = new ChatOpenAI({
  apiKey: process.env.OPENAI_API_KEY,
  configuration: { baseURL: process.env.OPENAI_BASE_URL },
  model: process.env.MODEL_NAME ?? "qwen-plus",
  temperature: 0,
})

// 从已有 Milvus collection 构造 VectorStore,不在在线链路里重复建库
const vectorStore = await Milvus.fromExistingCollection(embeddings, {
  collectionName: process.env.MILVUS_COLLECTION ?? "rag_docs",
  url: process.env.MILVUS_URI ?? "http://localhost:19530",
})

// k=4 表示每次召回 4 个片段,这是后续评测可以重点调优的参数
const retriever = vectorStore.asRetriever({ k: 4 })

这里有两个细节值得注意。

第一,在线检索使用的 Embedding 模型必须和入库时一致。否则用户问题和文档向量不在同一个语义空间里,相似度就失去意义。很多 RAG 问题表面看是"模型答偏了",实际是入库和查询用了不同 embedding 配置。

第二,temperature=0 不代表输出绝对确定,但它能减少评测噪声。做效果回归时,默认应该先降低随机性,否则你很难判断分数变化来自代码改动还是采样波动。

Prompt 的约束也很关键:

js 复制代码
const prompt = ChatPromptTemplate.fromMessages([
  [
    "system",
    // 明确要求模型只根据上下文回答,缺失信息要说不知道
    "你是客服助手。仅根据下面「上下文」回答;上下文没有的信息请明确说不知道,不要编造。\n\n上下文:\n{context}",
  ],
  ["human", "{question}"],
])

// Prompt -> LLM -> 字符串解析,构成生成阶段的子链路
const chain = RunnableSequence.from([prompt, llm, new StringOutputParser()])

这段 system prompt 的意义不是"让模型更礼貌",而是给 RAG 设置边界:答案必须受检索上下文约束。没有这个约束,模型很可能用通用知识补齐缺失信息,短期看回答更完整,长期看会污染业务可信度。

接下来是 Graph State 和两个节点:

js 复制代码
const GraphState = Annotation.Root({
  // 原始用户问题
  question: Annotation,

  // retrieve 节点写入的召回文档
  context: Annotation,

  // generate 节点写入的最终回答
  answer: Annotation,
})

async function retrieve(state) {
  // 根据用户问题检索 Milvus,返回 LangChain Document 列表
  const docs = await retriever.invoke(state.question)

  // 把召回结果写入图状态,后续 generate 节点会读取它
  return { context: docs }
}

async function generate(state) {
  // 把多个召回片段拼成 prompt 中的上下文
  const contextText = state.context.map(d => d.pageContent).join("\n\n")

  // 调用生成链路,要求模型基于上下文回答用户问题
  const answer = await chain.invoke({
    context: contextText,
    question: state.question,
  })

  // 返回最终答案,写入图状态
  return { answer }
}

retrievegenerate 分开,是为了让 Trace 变得可解释。线上出问题时,你可以直接看:

  • retrieve 节点输入的问题是什么?
  • 它召回了哪几个 Document?
  • metadata 里的 source 是哪个文件?
  • generate 节点拿到的上下文是否完整?
  • LLM 的 prompt、耗时和输出是什么?

如果把这些逻辑塞进一个函数,LangSmith 当然还能记录一次外层调用,但你会失去关键中间态。

最后把图编译出来:

js 复制代码
const workflow = new StateGraph(GraphState)
  // 第一个业务节点:负责召回上下文
  .addNode("retrieve", retrieve)
  // 第二个业务节点:负责基于上下文生成答案
  .addNode("generate", generate)
  // 从 START 进入检索节点
  .addEdge(START, "retrieve")
  // 检索完成后进入生成节点
  .addEdge("retrieve", "generate")
  // 生成完成后结束
  .addEdge("generate", END)

export const ragApp = workflow.compile()

export async function ask(question) {
  // 对外暴露一个简单函数,方便 CLI 和评测脚本复用同一套 RAG 逻辑
  const result = await ragApp.invoke({ question })

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

这里的 ask(question) 是一个很小但很重要的抽象。CLI 运行、LangSmith Evaluation、未来的 HTTP API 都应该复用它。否则你很容易出现"命令行是一套逻辑、评测是一套逻辑、线上服务又是一套逻辑"的问题,最后评测结果不能代表真实系统。

一次请求在 LangSmith 里应该怎么看

当 LangSmith tracing 开启后,LangChain / LangGraph 相关调用会被记录成一棵 Run Tree。对这个示例来说,一次问题大致会形成这样的结构:

sequenceDiagram participant User as 用户 participant CLI as cli.mjs participant Graph as LangGraph ragApp participant Retriever as Milvus Retriever participant LLM as ChatOpenAI participant LS as LangSmith User->>CLI: 输入客服问题 CLI->>Graph: ask(question) Graph->>LS: 记录 graph run 开始 Graph->>Retriever: retrieve(state) Retriever->>LS: 记录检索输入与返回文档 Retriever-->>Graph: context documents Graph->>LLM: generate(context, question) LLM->>LS: 记录 prompt、输出、耗时、token LLM-->>Graph: answer Graph->>LS: 记录 graph run 结束 Graph-->>CLI: answer + context CLI-->>User: 打印答案和引用片段

我排查 RAG 问题时,一般按这个顺序看 Trace。

先看最终答案是否满足业务预期。如果答案明显错,再看 retrieve 返回的 context。如果 context 里没有正确依据,优先查入库、切分、Embedding、检索参数;如果 context 里有正确依据但答案仍然错,优先查 Prompt、上下文拼接、模型能力和输出约束。

这套顺序能避免一个常见误区:一看到回答错,就急着换模型。模型当然重要,但 RAG 的错误很大一部分来自检索和上下文组织。没有 Trace,你只能猜;有 Trace,你可以沿着链路定位。

CLI:让人工调试也保留引用片段

项目里的 src/cli.mjs 用来批量问几个默认问题。它的价值不只是"跑通 demo",而是把答案和引用片段同时打印出来,便于人工快速检查召回质量。

js 复制代码
// 加载环境变量,保证 CLI 与评测脚本使用同一套配置
import dotenv from "dotenv"
dotenv.config({ override: true })

import { ask } from "./rag_agent.mjs"

// 默认问题覆盖售后、物流、会员、发票、质保、客服等典型场景
const DEFAULT_QUESTIONS = [
  "无理由退货要在几天内?",
  "满多少元包邮?",
  "金卡会员有什么折扣?",
  "电子发票多久能开好?",
  "手机保修多久?",
  "紧急问题怎么联系客服?",
]

// 如果命令行传了问题,就只问这一题;否则跑默认问题集
const args = process.argv.slice(2)
const questions = args.length > 0 ? [args.join(" ")] : DEFAULT_QUESTIONS

function printContext(context) {
  if (!context.length) {
    console.log("\n引用片段: (无)")
    return
  }

  console.log("\n引用片段:")
  context.forEach((doc, i) => {
    // source 可以帮助我们判断召回是否来自正确知识文件
    const source = doc.metadata?.source ?? "未知"

    // 只打印前 100 个字符,避免 CLI 输出被长文档淹没
    const text = doc.pageContent.replace(/\s+/g, " ").trim()
    const preview = text.length > 100 ? `${text.slice(0, 100)}...` : text

    console.log(`  [${i + 1}] ${source}`)
    console.log(`      ${preview}`)
  })
}

这段 CLI 在真实项目里可以继续演进成一个"调试入口"。比如支持打印 trace URL、支持指定 k、支持选择 collection、支持把本次问答保存为待评测样本。很多团队一开始没有评测集,就是从人工调试 case 慢慢沉淀出来的。

从观测到评估:Dataset、Evaluator、Experiment 怎么分工

Trace 解决的是"单次请求为什么这样运行"。但只看 Trace,不能回答"这个版本整体效果是否更好"。

这就需要 Evaluation。LangSmith 的评估链路可以拆成 3 个核心概念:

  • Dataset:测试样本集合,通常包含输入和参考输出。
  • Evaluator:评分函数,输入样本、模型输出和可选参考答案,返回分数或反馈。
  • Experiment:某一次评测运行,记录目标函数在整个 Dataset 上的输出和各项评分。

这三个概念的关系很像传统软件测试:

text 复制代码
Dataset 类似测试用例集
Target function 类似被测函数
Evaluator 类似断言和评分器
Experiment 类似一次测试报告

但 AI 评测比单元测试复杂,因为很多任务没有唯一标准答案。例如"金卡会员有什么权益?"可以有多种表达,只要包含 95 折、专属客服、每月优惠券等关键事实就算合理。这时用严格字符串匹配就不合适,LLM-as-judge 会更实用。

构建 Dataset:标准答案不是越长越好

当前项目的 src/eval/build_dataset.mjs 创建了一个 rag-eval-v1 数据集,覆盖售后、物流、支付、会员、质保和客服入口。

js 复制代码
// 加载 LangSmith API Key 等环境变量
import dotenv from "dotenv"
dotenv.config({ override: true })

import { Client } from "langsmith"

// 数据集名称需要稳定,后续 evaluate 会通过名称读取它
const DATASET_NAME = "rag-eval-v1"

// 每条样例都包含输入问题和参考答案
// 参考答案应覆盖关键事实,但不必追求和模型输出逐字一致
const EXAMPLES = [
  {
    inputs: { question: "无理由退货要在几天内申请?" },
    outputs: { answer: "自签收之日起 7 天内支持无理由退货。" },
  },
  {
    inputs: { question: "质量问题换货期限是多久?" },
    outputs: { answer: "15 天内出现质量问题可免费换货。" },
  },
  {
    inputs: { question: "满多少元包邮?" },
    outputs: { answer: "满 99 元包邮(部分大件/冷链除外)。" },
  },
  {
    inputs: { question: "金卡会员有什么折扣?" },
    outputs: { answer: "金卡享 95 折,并有专属客服和每月满 200 减 30 券。" },
  },
]

这里的参考答案应该"短而准"。如果标准答案写得过长,Evaluator 可能会过度惩罚模型没有覆盖不必要细节;如果标准答案太泛,又无法区分真正答对和擦边答对。

创建数据集的代码如下:

js 复制代码
async function main() {
  // Client 用于操作 LangSmith Dataset 和 Example
  const client = new Client({ apiKey: process.env.LANGCHAIN_API_KEY })

  let dataset
  try {
    // 如果数据集已存在,复用它,避免每次创建新数据集导致历史分散
    dataset = await client.readDataset({ datasetName: DATASET_NAME })
    console.log(`数据集已存在: ${DATASET_NAME}`)
  } catch {
    // 第一次运行时创建数据集
    dataset = await client.createDataset(DATASET_NAME, {
      description: "RAG Agent 回归评估集",
    })
    console.log(`已创建数据集: ${DATASET_NAME}`)
  }

  // 把本地样例写入 LangSmith,后续评测会按这些样例批量调用目标函数
  const created = await client.createExamples(
    EXAMPLES.map(e => ({
      dataset_id: dataset.id,
      inputs: e.inputs,
      outputs: e.outputs,
    }))
  )

  console.log(`已创建 ${created.length} 条样例`)
}

这段脚本还有一个可改进点:当前每次运行都会继续创建 examples,如果重复执行,可能产生重复样本。生产项目里可以给样本加稳定 ID、metadata 或先清理同名样本,避免评测集膨胀。作为教学 demo,它已经足够表达 Dataset 的角色;作为团队工具,还需要样本生命周期管理。

Evaluator:RAG 评估不能只看最终答案

RAG 的评估至少应该分三层看:

第一层,检索相关性。召回的上下文是否和问题相关?如果检索错了,生成阶段再强也只是补救。

第二层,忠实度。最终答案是否被召回上下文支撑?这能衡量幻觉风险。

第三层,有用性。答案是否真正回应用户问题?有些答案虽然忠实,但没有解决用户需求。

项目里的 src/eval/evaluators.mjs 正好用了 OpenEvals 里三类 RAG prompt:

  • RAG_GROUNDEDNESS_PROMPT:检查答案是否被上下文支撑。
  • RAG_HELPFULNESS_PROMPT:检查答案是否有用、是否切题。
  • RAG_RETRIEVAL_RELEVANCE_PROMPT:检查召回片段是否和问题相关。

整理后的代码如下:

js 复制代码
/**
 * OpenEvals 内置 RAG 指标:
 * - groundedness:答案是否忠实于检索上下文
 * - helpfulness:答案是否真正回应用户问题
 * - retrieval relevance:召回文档是否与问题相关
 */
import {
  createLLMAsJudge,
  RAG_GROUNDEDNESS_PROMPT,
  RAG_HELPFULNESS_PROMPT,
  RAG_RETRIEVAL_RELEVANCE_PROMPT,
} from "openevals"
import { ChatOpenAI } from "@langchain/openai"

// Judge 模型负责打分,建议使用稳定、低随机性的模型配置
const judge = new ChatOpenAI({
  apiKey: process.env.OPENAI_API_KEY,
  configuration: { baseURL: process.env.OPENAI_BASE_URL },
  model: process.env.MODEL_NAME ?? "qwen-plus",
  temperature: 0,
})

// 忠实度:检查 answer 里的事实是否能从 context 中找到依据
const ragGroundednessJudge = createLLMAsJudge({
  prompt: RAG_GROUNDEDNESS_PROMPT,
  feedbackKey: "rag_groundedness",
  judge,
  continuous: true,
})

// 有用性:检查 answer 是否回答了 inputs 中的问题
const ragHelpfulnessJudge = createLLMAsJudge({
  prompt: RAG_HELPFULNESS_PROMPT,
  feedbackKey: "rag_helpfulness",
  judge,
  continuous: true,
})

// 检索相关性:只看 inputs 和 context,不看最终 answer
const ragRetrievalRelevanceJudge = createLLMAsJudge({
  prompt: RAG_RETRIEVAL_RELEVANCE_PROMPT,
  feedbackKey: "rag_retrieval_relevance",
  judge,
  continuous: true,
})

continuous: true 表示希望拿到连续分数,而不是简单的二分类。做版本对比时,连续分数更有用,因为它能表达"略有改善"或"明显退化"。

三个 evaluator 的入参不同,这一点非常关键:

js 复制代码
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 },
  })
}

// evaluate 会依次调用这些 evaluator,并把分数写入 Experiment
export const ragEvaluators = [
  ragGroundednessEvaluator,
  ragHelpfulnessEvaluator,
  ragRetrievalRelevanceEvaluator,
]

为什么要拆这么细?因为它能帮助定位问题。

如果 retrieval_relevance 低,优先看切分、Embedding、topK、向量索引和 query 表达。如果 retrieval_relevance 高但 groundedness 低,说明检索已经给了依据,但模型生成时乱加内容,应该改 Prompt、上下文格式或模型。如果 groundedness 高但 helpfulness 低,说明答案虽然没胡说,却没有解决用户意图,可能要做问题改写、意图识别或答案结构优化。

这就是指标拆分的工程价值。

运行 Experiment:评测的是目标函数,不是某段孤立代码

src/eval/run_eval.mjs 是评测入口。它把 ask(question) 包装成 LangSmith Evaluation 的 target function,然后交给 evaluate 批量执行。

js 复制代码
/**
 * RAG 评测入口:
 * Dataset 提供问题和参考答案,
 * runRagAgent 是被测目标函数,
 * ragEvaluators 负责从不同维度打分。
 */
import dotenv from "dotenv"
dotenv.config({ override: true })

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"

// Client 负责把评测过程和结果写入 LangSmith
const client = new Client({ apiKey: process.env.LANGCHAIN_API_KEY })

async function runRagAgent(inputs) {
  // evaluate 会把每条 Dataset example 的 inputs 传进来
  const { answer, context } = await ask(inputs.question)

  return {
    // 最终回答用于 helpfulness 和 groundedness 评估
    answer,

    // context 转成字符串数组,方便 evaluator prompt 使用
    context: context.map(d => d.pageContent),
  }
}

注意这里没有重新实现 RAG,而是复用了 ask。这是一个重要工程原则:评测必须尽量贴近真实调用链路。否则你评测的是一个"评测专用版本",不是实际服务。

运行评测的核心代码:

js 复制代码
async function main() {
  const result = await evaluate(runRagAgent, {
    // 指定要使用的 LangSmith Dataset
    data: DATASET_NAME,

    // 指定多个 evaluator,分别产出不同指标
    evaluators: ragEvaluators,

    // 传入 LangSmith Client,便于复用当前认证配置
    client,

    // 实验名前缀带上模型名,方便不同模型版本对比
    experimentPrefix: `rag-openevals-${process.env.MODEL_NAME ?? "qwen"}`,

    // 控制并发,避免模型服务或向量库被评测请求打爆
    maxConcurrency: 2,
  })

  // 消费异步结果流,确保所有样例都完成
  for await (const _row of result) {
    // 这里不需要逐行处理,drain 即可
  }

  const project = process.env.LANGCHAIN_PROJECT ?? "default"
  console.log("评测完成")
  console.log("实验名:", result.experimentName)
  console.log("指标: rag_groundedness | rag_helpfulness | rag_retrieval_relevance")
  console.log(
    `报告: https://smith.langchain.com/o/default/projects/p/${encodeURIComponent(project)}`
  )
}

maxConcurrency=2 是一个朴素但必要的保护。评测本质上是在批量调用你的 RAG 应用,每条样本还可能触发 evaluator 的 judge 模型调用。如果样本数从 12 条扩到 1000 条,并发不加控制,轻则 API 限流,重则把向量库或模型网关打挂。

参数怎么调:不要把默认值当真理

这个示例里有几个参数非常适合拿来做实验。

chunkSize=500:影响召回片段粒度。客服政策类文档适合中等 chunk;如果是长篇技术文档,可以尝试按标题结构切分,而不是只靠字符数。

chunkOverlap=50:影响跨段语义连续性。重叠太小会切断上下文,重叠太大会增加重复召回和存储成本。

k=4:影响召回数量。k 太小容易漏召回,k 太大容易把噪声塞进 prompt。对 RAG 来说,更多上下文不等于更好上下文。

temperature=0:评测阶段建议低随机性。面向真实用户时是否提高温度,要看业务类型。客服问答、政策解释、合规问答通常不应该追求发散表达。

IndexType.IVF_FLATMetricType.L2nlist=128:影响 Milvus 检索性能和召回效果。数据量小的时候差异不明显,数据量上来后要专门做索引实验。

maxConcurrency=2:影响评测速度和稳定性。并发越高跑得越快,但越容易触发限流,也越难区分系统问题和外部服务抖动。

这些参数都不应该靠"经验拍脑袋"定死。更合理的方式是:每次只改一类变量,跑同一个 Dataset,比较 Experiment 的分数、耗时和成本。

常见误区:LangSmith 不是接上就万事大吉

第一个误区是只看最终答案,不看检索片段。RAG 的生成质量高度依赖上下文。如果召回错了,模型输出再流畅也没有业务可信度。

第二个误区是把 LLM-as-judge 当成绝对真理。Evaluator 本身也是模型调用,也会有偏差。关键业务指标最好引入人工抽检,或者把高风险样本放入 annotation queue 做人工标注。

第三个误区是评测集只覆盖 happy path。真实业务里最容易出问题的是边界问题:文档没有答案、问题表达模糊、多个政策冲突、过期规则和新规则并存。Dataset 里必须有"应该回答不知道"的样本。

第四个误区是每次改动同时改太多东西。你同时换模型、改 prompt、改 chunk、改 k,分数变好也不知道是谁带来的,分数变差也不知道该回滚哪里。

第五个误区是忽略隐私和成本。Trace 里可能包含用户问题、业务数据、工具返回和模型输出。生产环境接入前,要明确哪些字段可以上报、哪些字段需要脱敏、trace 保留多久、谁有权限查看。

我会怎么把这个 demo 演进成生产方案

如果这套电商客服 RAG 要继续往真实业务走,我会优先补四件事。

第一,数据入库要从"每次 drop collection 重建"演进为可增量更新。demo 这样写很干净,但生产里不能每次更新一份文档就重建全库。更合理的做法是给文档和 chunk 设计稳定 ID,支持按 source 删除、更新和回滚。

第二,Dataset 要分层。不要把所有问题塞进一个大集合里。可以按售后、物流、支付、会员、质保拆分,也可以按 smoke、regression、edge、security 拆分。这样某次实验退化时,能快速知道问题集中在哪类业务。

第三,Evaluator 要组合使用。RAG 三指标是基础,但客服场景还可以加"是否包含联系方式""是否违规承诺""是否泄露内部规则""语气是否符合客服规范"等业务 evaluator。通用指标解决不了所有业务质量问题。

第四,把 Trace 和用户反馈打通。线上用户点踩、转人工、重复追问,都应该能回连到当次 Trace。这样你才能从真实失败样本反向扩充 Dataset,而不是只靠开发者自己构造问题。

小结

LangSmith 最值得学习的地方,不是它多了一个 Trace 页面,而是它把 AI 应用的两类关键反馈做成了工程闭环。

Trace 让你能复盘一次请求:检索了什么、调用了什么、耗时多少、token 消耗多少、哪一步失败、最终答案怎么生成。

Dataset、Evaluator 和 Experiment 让你能复盘一个版本:同一批样本上,检索相关性、答案忠实度、回答有用性有没有变化,哪类问题退化,哪次改动真正有效。

对 Agent 和 RAG 来说,这个闭环比"跑通 demo"重要得多。因为模型能力会变,业务知识会变,Prompt 会变,索引和切分策略也会变。没有观测和评估,你只能靠感觉维护系统;有了 LangSmith 这样的工具,你才能把问题变成可定位、可比较、可持续优化的工程对象。

这也是我对这类工具的判断:它不是锦上添花的可视化后台,而是 AI 应用从实验室走向业务系统时必须补上的仪表盘和回归测试框架。

参考资料

相关推荐
我是一颗柠檬2 小时前
【Redis】字符串与哈希Day3(2026年)
数据库·redis·后端·database
swipe2 小时前
Neo4j + Graph RAG 工程实践:RAG 真正缺的不是更多文本,而是可查询的关系
后端·面试·llm
神奇小汤圆2 小时前
告别OOM焦虑:Flink 内存模型原理与诊断调优
后端
Raink老师2 小时前
【AI面试临阵磨枪-088】Skill 如何做参数校验、依赖注入、权限控制、超时、重试、幂等?
人工智能·面试·职场和发展
神奇小汤圆2 小时前
Kafka性能调优:从10万到100万条/秒的实战经验
后端
nuowenyadelunwen2 小时前
CS336 Assignment 1 BPE分词器训练初版(朴素版基础上优化)及后续优化方向分析
llm·cs336
Gopher_HBo2 小时前
接入层LVS
后端
404号扳手2 小时前
Java 基础知识(六)
java·后端
前端市界2 小时前
LotDB Vue 阿里云 ECS 部署实战记录
后端