做 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。
如果只看业务代码,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=500 和 chunkOverlap=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 拆成 retrieve 和 generate 两个节点。这是一个很好的示例,因为 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 }
}
把 retrieve 和 generate 分开,是为了让 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。对这个示例来说,一次问题大致会形成这样的结构:
我排查 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_FLAT、MetricType.L2、nlist=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 应用从实验室走向业务系统时必须补上的仪表盘和回归测试框架。
参考资料
- LangSmith Tracing Quickstart:docs.langchain.com/langsmith/o...
- LangSmith Evaluation Quickstart:docs.langchain.com/langsmith/e...
- OpenEvals:github.com/langchain-a...
- 本文示例源码:
/Users/zz/AI learning/codeing/tool-test/langsmith-test