🚥 给 RAG 装一台心电监护仪:LangSmith 全链路观测(上)

写在前面:前面几节课我们把 RAG 从"能跑"一路升级到了"会分诊、会拆题、会联网、会混合检索"。但有个问题一直悬着------你怎么知道它到底好不好? readme 把这个痛感描述得极其生动:"langchain/langgraph 开发 Agent,强烈的'盲盒感'。调用哪个工具?每一步耗时多少?流失的 Token,消耗了多少 token?" 然后甩出一句管理学里的老话:"如果你无法度量它,你就无法管理它。" 上篇先解决"看见"的问题------用 LangSmith 把 Agent 的每一步照亮;下篇再解决"衡量"的问题------建一套考试系统,请三个 AI 考官打分。以下所有代码均来自课堂真实文件。


一、要拆盲盒,先分清两件事

"给 Agent 加观测"听起来是一件事,其实是两件完全不同的事:

工具 回答什么问题 医疗类比
LangSmith Tracing / Monitoring 这一次跑得怎么样?哪一步慢、哪一步错 心电监护仪(实时看)
LangSmith Datasets / Evaluators 我这套系统整体能打几分 全身体检 + 体检报告

监护仪是实时的、单次的------它告诉你"刚才那一下心跳异常"。

体检是批量的、标准的------它告诉你"你的各项指标分别是多少分、比上次好还是差了"。

两个缺一不可。 只有监护仪,你永远不知道系统整体水平;只有体检,出了故障你不知道是哪一步出的。

readme 把 LangSmith 的四个核心功能列得很清楚:

"Tracing 追踪 bug,调试 Agent,每次 Agent 的执行。 Monitoring Agent 后台实时监控,llm token 开销、时间、工具。 Datasets 数据集,问题-回答对。 Evaluators 评估器,评估 Agent 的回答。"

前两个是监护仪,后两个是体检系统。 上篇只讲前两个------怎么让 Agent 的每一步都"亮起来"。


二、先得有个人被监护:客服 RAG Agent

要装监护仪,先得有个活体。这就是 rag_agent.mjs------一个客服问答 RAG。

图结构:老三样

javascript 复制代码
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)
// 设计 -》 Agent
export const ragApp = workflow.compile();

又是那条最朴素的直线 ------retrieve → generate。这跟第一篇 RAG 的结构完全一致。

这有点意思:前面学了那么多花哨能力(分诊、拆题、联网、混合检索),这个 demo 却回到了最简版本。

为什么?因为接下来的主角不是"怎么把 RAG 做得更强",而是"怎么衡量它有多强"。

衡量系统要有个稳定的基准------用一个结构清晰的简单 RAG 来演示观测和评估,比拿一个五层嵌套的 Agentic RAG 更容易看清每一步在干嘛。被测对象要尽量简单,这样结果才好归因。

prompt:模板的经典写法

javascript 复制代码
const prompt = ChatPromptTemplate.fromMessages([
    [
        "system",
        `你是客服助手。仅根据下面[上下文]回答:上下文没有的信息请明确说明,不要编造。\n\n
        上下文:{context}
        `
    ],
    [
        "human",
        "{question}"
    ]
]);

这是个严格遵守上下文的 system prompt------"仅根据上下文回答""不要编造"。这和前面 RAG 篇里那五条防幻觉要求是同一个思路。

为什么在这里单独提这一点? 因为下篇有个评估器专门查这条规矩有没有被守住------"答案有没有依据"(groundedness)。 你在 prompt 里立了规矩,就得有人查你有没有守住。

注意 ChatPromptTemplate.fromMessages 这个写法------用数组描述多轮对话结构 ,每条是 [角色, 内容]。这比手拼字符串清晰得多:角色和内容分开写,AI 一看就知道哪句是系统指令、哪句是用户提问。

用 RunnableSequence 串起来

javascript 复制代码
import { StringOutputParser } from "@langchain/core/output_parsers";
import { RunnableSequence } from "@langchain/core/runnables";

// 线性
const chain = RunnableSequence.from([prompt, LLM, new StringOutputParser()]);

这行代码是 LangChain 早期玩法的活化石。

prompt → LLM → parser 串成一条链------这就是 LangChain 最早、最经典的用法(那会儿还没有 LangGraph)。现在这个 chain 被塞进了 LangGraph 的 generate 节点里。

两代框架在同一份文件里共存:外层用 LangGraph 编排流程(因为要支持分支循环),内层用 RunnableSequence 做线性处理(因为这里确实只是线性的)。

StringOutputParser 的作用是只取字符串输出------注释写得很直白:

javascript 复制代码
// 输出解析器 只要字符串输出

模型返回的本来是个带元信息(token 用量、结束原因等)的对象,经过这一层,只留下最干净的文本。

关键设计:导出 ask 函数

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

这六行是整份文件的灵魂。

注意它返回了两个东西:

返回 用来干嘛
answer 最终答案
context 检索到的文档

readme 的第二份文件只有两行,把它说透了:

"# Rag Agent 量化评估

  • 召回的文档的质量
  • 回答的质量"

要评估 RAG,必须评两件事------召回的文档质量、生成的回答质量。 所以 ask 必须把 context 一起交出来。

如果只返回 answer,你只能评估"回答像不像样",永远不知道"是不是检索环节就烂了"。 一个答案不好,可能是检索没找到料,也可能是料给了但生成胡编------要区分这两种情况,就必须拿到中间产物。

这就是"为了可观测(和可评估)而设计的接口"。 一个好接口不只是完成任务,还要把过程暴露出来。

result.context ?? [] 那个兜底也值得留意------万一没检索到任何东西,返回空数组而不是 undefined。调用方就不用到处写"如果为空"的判断了。


三、给 Agent 备料:Milvus 数据流水线

Agent 有了,它得有事可干。milvus_insert.mjs 负责把资料灌进向量库。

第一步:扫描数据目录

javascript 复制代码
import {
    existsSync, // 同步
    readFileSync,
    readdirSync
} from "fs";// 异步 (默认) 同步 Sync

async function loadChunks(dataDir = "./data") {
    if (!existsSync(dataDir)) {
        throw new Error(`Data directory ${dataDir} does not exist`);
    }
    console.log(readdirSync(dataDir), "files");
    const file = readdirSync(dataDir).filter((f) => /\.(text|md)$/.test(f));
    if (file.length === 0) {
        throw new Error(`No files found in ${dataDir}`);
    }

    const docs = file.map(f => ({
        pageContent: readFileSync(join(dataDir, f), "utf-8"),
        metadata: {
            source: f,
        }
    }))

几条值得注意的:

第一,那行注释点出了 Node.js 的一个核心观念:

javascript 复制代码
import {
    existsSync, // 同步
    readFileSync,
    readdirSync
} from "fs";// 异步 (默认) 同步 Sync

fs 模块的方法默认是异步的 ,带 Sync 后缀的是同步版本。注释里的"node 异步无阻塞的性能好 no blocking async"说的就是这个设计取向。

同步版本会阻塞事件循环 ------读一个大文件期间,整个进程啥也干不了。但在这个初始化脚本里,用同步版本反而更简单(不用层层 await),而且反正是启动时跑一次。选同步还是异步,看场景,不是看"哪个更高级"。

第二,文件过滤用正则:

javascript 复制代码
const file = readdirSync(dataDir).filter((f) => /\.(text|md)$/.test(f));

只挑 .text 和 .md 文件。这比手写 f.endsWith('.md') || f.endsWith('.text') 更简洁,而且以后想加格式(比如 .txt)只改一处。

第三,给每段内容打上"户口":

javascript 复制代码
metadata: {
    source: f,      // 文件名
}

source 记下这段文字来自哪个文件。这是后面"答案可追溯"的基础------如果 Agent 答错了,你能翻开原始资料核对。

第四,切块复用老配方:

javascript 复制代码
const splitter = new RecursiveCharacterTextSplitter({
    chunkSize: 500,
    chunkOverlap: 50,
});
return splitter.splitDocuments(docs);

500 字一块、重叠 50 字------跟前面 RAG 篇《天龙八部》的配方一模一样。 这说明这两个参数已经成了某种"经验默认值"。

注意这里用的是 splitDocuments(接收文档对象数组,返回带 metadata 的文档对象),而不是前面用过的 splitText(接收字符串,返回字符串数组)。区别就在于 ------splitDocuments 会把 metadata 一路带到每个切片上。所以切完之后,每一块都还记着自己来自哪个文件。

第二步:重建集合

javascript 复制代码
if((await client.hasCollection({ collection_name:COLLECTION })).value) {
    await client.dropCollection({ collection_name:COLLECTION });
    console.log(`Collection ${COLLECTION} dropped\n`);
}

先查、有就删------这是个很有意思的决策。

为什么不"增量更新"(只插入新增的数据)而要"整个删掉重建"?对于一个开发阶段的初始化脚本来说,重建有三个好处:

重建 增量更新
结果确定------跑完一定是"当前数据的完整镜像" 状态依赖历史------可能残留脏数据
逻辑简单------不用比对差异 要处理 ID 冲突、删除、更新
可重复运行------跑一百次结果一样 跑两次可能变两样

"宁可全量重来,也要结果确定" ------这是初始化脚本该有的性格。数据量大的时候当然不行,但对着一个 ./data 目录跑,完全没问题。

第三步:动态获取向量维度

javascript 复制代码
const vectors = await embeddings.embedDocuments(
    chunks.map(c => c.pageContent)
);
console.log(vectors);
const dim = vectors[0].length;

这两行很聪明。

dim 不是写死的 1024,而是从第一次 embedding 的真实结果里量出来的。

对比一下前面几份文件------那些都写死了 const VECTOR_DIM = 1024。写死的问题在于:一旦换了 embedding 模型,维度变了(比如从 1024 变成 1536),你得记得改这个常量。 忘了改,建出来的集合维度跟实际向量对不上,插入就报错。

从数据里量出来,就永远不会对不上。 这是一种"让代码自己发现事实"的思路。

embedDocuments 也是个新面孔------前面用过 embedQuery(单条),这里是 embedDocuments(批量)。一个用于"入库时批量转向量",一个用于"查询时转单条",各有各的场景,别用混。

第四步:建集合,注意字段命名

javascript 复制代码
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, },
    ]
});

字段名里的 langchain_ 前缀是关键。

前面几份文件建的集合,字段叫 id、vector、content------那是"自己定义、自己用"。而这里用的是 LangChain Milvus 集成的约定字段名。

为什么这么做?因为 rag_agent.mjs 里那行代码:

javascript 复制代码
const vectorStore = await Milvus.fromExistingCollection(embeddings, {
    collectionName: process.env.MILVUS_COLLECTION ?? "rag_docs",
    url: process.env.MILVUS_URL ?? "http://localhost:19530",
});

注意:这里只传了集合名,没传 textField、vectorField、primaryField。

对比前面 RAG 篇的写法:

javascript 复制代码
// 前面的写法,字段映射写得很全
url:"localhost:19530",
textField:"content",
primaryField:"id",
vectorField:"vector",

现在一个都不用传了------因为字段名就是 LangChain 默认约定的那些。 用它的命名规则建表,它就能零配置接入。

这是"约定优于配置"的一个典型例子。 代价是字段名不能随意取,收益是后续接入省掉一堆配置。

还有一个细节:autoID: true 让 Milvus 自动生成主键(整数自增)。前面我们是手写可读 ID(1_23_5),这里交给数据库生成------两种做法各有场景,手写 ID 适合需要精确定位的场景,自动 ID 适合纯粹"存了就行"的场景。

第五步:索引、加载、插入

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

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

注意度量方式变了------MetricType.L2(欧氏距离),不是前面用的 MetricType.COSINE(余弦相似度)。

度量 衡量什么 适合
COSINE 向量方向的夹角 关心语义方向,不关心长度
L2 向量距离(欧氏) 关心数值上的接近程度

两种都可以,选哪个取决于你的 embedding 模型和业务。但有个铁律:建索引时的度量方式,跟查询时的必须一致------否则算出来的"相似度"毫无意义。

nlist: 128 是 IVF_FLAT 的分桶数(前面学过,桶越多定位越准、桶内数据越少)。

插入数据的组装也很整齐:文本、来源、向量三件套,一一对应。

javascript 复制代码
langchain_text: chunk.pageContent,       // 文本
source: chunk.metadata.source,           // 溯源信息
langchain_vector: vectors[i],            // 向量(用下标对应)

注意 vectors[i] 这个下标------chunks 和 vectors 是靠位置一一对应的 ,因为 embedDocuments 是批量处理 chunks.map(c => c.pageContent) 得到的,顺序不会变。这是"批量处理"隐含的一个契约:输入顺序即输出顺序。

顺带一提,文件里注释了生产环境的形态:

javascript 复制代码
// 项目上线 milvus 独立于程序外 aliyun 服务
// MilvusClient 自动带上https

还有那行地址处理:

javascript 复制代码
const MILVUS_ADDRESS = process.env.MILVUS_URL.replace(/^https?:\/\//, "") ?? "localhost:19530";

正则是为了把 https:// 前缀去掉 ------因为 SDK 自己会处理协议。本地开发是 localhost:19530,云上是阿里云托管的 Milvus 服务,代码一行不改,只换环境变量。 这就是前面部署课讲的"配置与环境分离"。


四、手动跑一次:命令行入口

数据齐了、Agent 有了,先手动试一发。cli.mjs:

javascript 复制代码
// command line
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;
console.log(questions);

for (let i = 0; i < questions.length; i++) {
    const question = questions[i];
    console.log(`\n问题${i+1}:${question}`);
    const { answer, context } = await ask(question);
    console.log(`回答:${answer}`);
    console.log("--------------------");
    console.log(context);
    console.log("--------------------");
    console.log(`命中 ${context.length} 条上下文`);
}

这个 CLI 的取值逻辑值得看:

javascript 复制代码
const args = process.argv.slice(2);
const questions = args.length > 0 ? [args.join(" ")]: DEFAULT_QUESTIONS;
  • process.argv.slice(2) ------ Node 的前两个参数是 node 和脚本路径,真正的用户输入从第 3 个开始
  • args.join(" ") ------ 把多段参数拼回一句完整的话

为什么要 join?因为命令行输入长句会被拆成多个参数:

bash 复制代码
node cli.mjs 无理由退货 要在几天内
# args = ["无理由退货", "要在几天内"]  ← 被拆开了
# join(" ") → "无理由退货 要在几天内"  ← 拼回来

不拼的话,"无理由退货"和"要在几天内"会被当成两个独立问题------这正是向量检索最怕的"语义被切碎"。

注意 DEFAULT_QUESTIONS 的设计:不给参数时有个默认问题,直接回车就能跑。 这是开发脚本的常见贴心做法------省掉每次手敲测试用例的功夫。

最后那行日志也很有用:

javascript 复制代码
console.log(`命中 ${context.length} 条上下文`);

打出命中条数,是一个很轻量的健康检查。 如果某次查询命中 0 条,你会立刻意识到"检索环节出问题了"------不用等看到答案才猜。


五、让它亮起来:让 LangSmith 看见每一步

前面说 Agent 开发有"盲盒感"。怎么拆盲盒?

trigger-error.mjs 是今天最短但最妙的一个文件------它专门用来抛错。

javascript 复制代码
import "dotenv/config"
// 自动的根据.env langsmith 配置 去trace
// langchain,langgraph langsmith 打通的
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;
}

关键点一:LangSmith 是"自动接上"的

开头那两行注释,是这份文件最有价值的信息:

javascript 复制代码
// 自动的根据.env langsmith 配置 去trace
// langchain,langgraph langsmith 打通的

两句话,讲清了 LangSmith 的接入方式------不用写一行上报代码。

你只要在 .env 里配好 LangSmith 的 key,LangChain / LangGraph 就会自动把执行过程上报。因为它俩和 LangSmith 本来就是一套体系里的东西。

这跟前面 harness 那节课手写 print 日志的做法完全不同:

做法 成本 能看到什么
手写 console.log 每个节点都要加 只看到你想打的
LangSmith 自动 trace 零代码 每次调用的完整链路:输入输出、耗时、token、工具调用

"零代码接入"是它能成为标配的原因------如果观测要先写一堆埋点代码,大多数项目根本不会做。

而且这个图的写法也印证了"打通"二字------它就是最普通的 StateGraph,两节点一条线,没有任何为观测而写的特殊代码。

关键点二:故意造错,验证"错误路径也能被看见"

javascript 复制代码
const stepOk = (state) => ({ text: `${state.text} [ok]` });// 正常执行
// 不能正确的完成任务,没有返回值
const stepThrow = () => {
    throw new Error("DemoError:节点内故意跑错:(trigger-error.mjs)");
}

图的流程是 START → step_ok → step_throw → END------先走一个正常节点,再撞上一个抛错的节点。

为什么不直接抛错? 因为要验证的是"跑到一半失败了"这个场景:

复制代码
step_ok 执行完(有输出)  → 这一步应该能在 trace 里看到
step_throw 抛异常        → 这一步应该标红

在 LangSmith 的界面上,你会看到一条轨迹:第一个节点绿着、第二个节点红着、状态栏显示错误。

这才是有价值的观测------不是"成功时能看",而是"失败时看得清失败在哪一步"。

注释里那句"不能正确的完成任务,没有返回值"------抛错的节点确实什么也不返回,这也解释了一个常见困惑:为什么我的图跑一半没输出了?因为节点抛异常,状态根本没往下传。

错误信息本身也写得很有心:

javascript 复制代码
throw new Error("DemoError:节点内故意跑错:(trigger-error.mjs)");

错误信息里带了 DemoError: 前缀(方便搜索过滤)和文件名(一眼知道去哪找)。这是一条"好错误信息"的标准:说清是什么错、错在哪。

关键点三:错误处理的规范写法

javascript 复制代码
try {
    await graph.invoke({ text: "start" });
    console.log("不应执行");
} catch(err) {
    console.error("已捕获:", err?.message ?? err);
    process.exitCode = 1;
}

两个细节:

第一,console.log("不应执行") ------成功分支里放一句"如果这行打印了说明有 bug"。 这比什么都不写更有表达力:读代码的人一眼就知道,走到这里是不对的。

第二,process.exitCode = 1 而不是 process.exit(1)。

这个区别很实在:

写法 行为
process.exit(1) 立即强制退出------可能截断还没写完的输出、跳过清理逻辑
process.exitCode = 1 设置退出码,让进程自然结束------输出能正常刷完,清理逻辑还能跑

对于"要把日志打完再退出"的脚本,后者明显更稳妥。这是个小细节,但能看出对 Node 运行机制的理解。

err?.message ?? err 也是个好习惯------有些异常对象没有 message 属性(比如抛出的是字符串而不是 Error),那就直接把整个错误打出来,保证不会打出 undefined。

所以这个文件在实战中怎么用?跑一次它,然后去 LangSmith 界面看那条红色轨迹。 验证完"错误能被观测到",你就可以放心地在真实项目里排查问题了。


六、上篇小结:监护仪装好了,但还缺一份体检报告

上篇五件事:

步骤 文件 干的事
1 rag_agent.mjs 造一个被测对象,ask() 同时交出 answer 和 context
2 milvus_insert.mjs 备料:切块、转向量、灌进 Milvus
3 cli.mjs 手动跑一次,肉眼看看效果
4 trigger-error.mjs 故意抛错,验证错误也能被观测到
5 LangSmith 界面 自动接上,看完整 trace 轨迹

现在你能回答这些问题了: 这次调用走了哪些节点?每个节点花了多久?消耗了多少 token?哪一步出错了?

但你还回答不了另一个问题:这套系统整体能打几分?

监护仪只能在运行的时候看------它是实时的、单次的 。而"我这版 RAG 比上一版好还是差",需要的是批量的、有标准的评估。

那就得准备一份体检报告。下篇:给 RAG 建一套考试系统,请三个 AI 考官打分。


PS:上篇里我最喜欢那个"故意抛错"的文件------为了验证观测链路,专门造一个错误出来。这体现了一种很成熟的工程习惯:先确认"我能看见坏的情况",再去处理坏的情况。 否则线上真出问题的时候,你连问题在哪都找不到。

相关推荐
前端snow2 小时前
ai agent --- mem0 外挂记忆系统
前端
变与不变8063 小时前
js同步和异步难点重点详解
开发语言·javascript·ecmascript
特立独行的猫a3 小时前
用仓颉写一个 Tauri:IPC 的每次往返实现原理(web层到仓颉层的触发过程)
前端·ui·harmonyos·tauri·鸿蒙·仓颉
Dovis(誓平步青云)3 小时前
多个链接不等于多份证据,新闻核验看板怎样合并来源
java·服务器·前端·javascript·人工智能·pdf·电脑
骑着蜗牛撵大象3273 小时前
Qt 事件机制详解:从 QEvent 派生类到事件过滤器的全景指南
前端·python
qq_2518364574 小时前
理发管理系统 —— 会员模块
开发语言·前端·python·flask
Dovis(誓平步青云)4 小时前
几个方案来回选不定?做一个随时切换的候选推荐页
android·java·服务器·开发语言·javascript·数据库·智能化
Frag0ut4 小时前
2027 年浏览器展望:Chrome 与 Edge 的 AI 智能体进化方向
前端·人工智能·chrome·edge·浏览器·新功能
计算机魔术师4 小时前
从AI绘图鼻祖到音乐版权破局者,Stability AI如何被三大唱片巨头「收编」
前端