写在前面:前面几节课我们把 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。
图结构:老三样
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:模板的经典写法
const prompt = ChatPromptTemplate.fromMessages([
[
"system",
`你是客服助手。仅根据下面[上下文]回答:上下文没有的信息请明确说明,不要编造。\n\n
上下文:{context}
`
],
[
"human",
"{question}"
]
]);
这是个严格遵守上下文的 system prompt——"仅根据上下文回答""不要编造"。这和前面 RAG 篇里那五条防幻觉要求是同一个思路。
为什么在这里单独提这一点? 因为下篇有个评估器专门查这条规矩有没有被守住——"答案有没有依据"(groundedness)。 你在 prompt 里立了规矩,就得有人查你有没有守住。
注意 ChatPromptTemplate.fromMessages 这个写法——用数组描述多轮对话结构,每条是 [角色, 内容]。这比手拼字符串清晰得多:角色和内容分开写,AI 一看就知道哪句是系统指令、哪句是用户提问。
用 RunnableSequence 串起来
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 的作用是只取字符串输出——注释写得很直白:
// 输出解析器 只要字符串输出
模型返回的本来是个带元信息(token 用量、结束原因等)的对象,经过这一层,只留下最干净的文本。
关键设计:导出 ask 函数
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 负责把资料灌进向量库。
第一步:扫描数据目录
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 的一个核心观念:
import {
existsSync, // 同步
readFileSync,
readdirSync
} from "fs";// 异步 (默认) 同步 Sync
fs 模块的方法默认是异步的,带 Sync 后缀的是同步版本。注释里的"node 异步无阻塞的性能好 no blocking async"说的就是这个设计取向。
同步版本会阻塞事件循环——读一个大文件期间,整个进程啥也干不了。但在这个初始化脚本里,用同步版本反而更简单(不用层层 await),而且反正是启动时跑一次。选同步还是异步,看场景,不是看"哪个更高级"。
第二,文件过滤用正则:
const file = readdirSync(dataDir).filter((f) => /\.(text|md)$/.test(f));
只挑 .text 和 .md 文件。这比手写 f.endsWith('.md') || f.endsWith('.text') 更简洁,而且以后想加格式(比如 .txt)只改一处。
第三,给每段内容打上"户口":
metadata: {
source: f, // 文件名
}
source 记下这段文字来自哪个文件。这是后面"答案可追溯"的基础——如果 Agent 答错了,你能翻开原始资料核对。
第四,切块复用老配方:
const splitter = new RecursiveCharacterTextSplitter({
chunkSize: 500,
chunkOverlap: 50,
});
return splitter.splitDocuments(docs);
500 字一块、重叠 50 字——跟前面 RAG 篇《天龙八部》的配方一模一样。 这说明这两个参数已经成了某种"经验默认值"。
注意这里用的是 splitDocuments(接收文档对象数组,返回带 metadata 的文档对象),而不是前面用过的 splitText(接收字符串,返回字符串数组)。区别就在于——splitDocuments 会把 metadata 一路带到每个切片上。所以切完之后,每一块都还记着自己来自哪个文件。
第二步:重建集合
if((await client.hasCollection({ collection_name:COLLECTION })).value) {
await client.dropCollection({ collection_name:COLLECTION });
console.log(`Collection ${COLLECTION} dropped\n`);
}
先查、有就删——这是个很有意思的决策。
为什么不"增量更新"(只插入新增的数据)而要"整个删掉重建"?对于一个开发阶段的初始化脚本来说,重建有三个好处:
| 重建 | 增量更新 |
|---|---|
| 结果确定——跑完一定是"当前数据的完整镜像" | 状态依赖历史——可能残留脏数据 |
| 逻辑简单——不用比对差异 | 要处理 ID 冲突、删除、更新 |
| 可重复运行——跑一百次结果一样 | 跑两次可能变两样 |
"宁可全量重来,也要结果确定" ——这是初始化脚本该有的性格。数据量大的时候当然不行,但对着一个 ./data 目录跑,完全没问题。
第三步:动态获取向量维度
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(批量)。一个用于"入库时批量转向量",一个用于"查询时转单条",各有各的场景,别用混。
第四步:建集合,注意字段命名
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 里那行代码:
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 篇的写法:
// 前面的写法,字段映射写得很全
url:"localhost:19530",
textField:"content",
primaryField:"id",
vectorField:"vector",
现在一个都不用传了——因为字段名就是 LangChain 默认约定的那些。 用它的命名规则建表,它就能零配置接入。
这是"约定优于配置"的一个典型例子。 代价是字段名不能随意取,收益是后续接入省掉一堆配置。
还有一个细节:autoID: true 让 Milvus 自动生成主键(整数自增)。前面我们是手写可读 ID(1_23_5),这里交给数据库生成——两种做法各有场景,手写 ID 适合需要精确定位的场景,自动 ID 适合纯粹"存了就行"的场景。
第五步:索引、加载、插入
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 的分桶数(前面学过,桶越多定位越准、桶内数据越少)。
插入数据的组装也很整齐:文本、来源、向量三件套,一一对应。
langchain_text: chunk.pageContent, // 文本
source: chunk.metadata.source, // 溯源信息
langchain_vector: vectors[i], // 向量(用下标对应)
注意 vectors[i] 这个下标——chunks 和 vectors 是靠位置一一对应的,因为 embedDocuments 是批量处理 chunks.map(c => c.pageContent) 得到的,顺序不会变。这是"批量处理"隐含的一个契约:输入顺序即输出顺序。
顺带一提,文件里注释了生产环境的形态:
// 项目上线 milvus 独立于程序外 aliyun 服务 // MilvusClient 自动带上https还有那行地址处理:
const MILVUS_ADDRESS = process.env.MILVUS_URL.replace(/^https?:\/\//, "") ?? "localhost:19530";正则是为了把
https://前缀去掉——因为 SDK 自己会处理协议。本地开发是localhost:19530,云上是阿里云托管的 Milvus 服务,代码一行不改,只换环境变量。 这就是前面部署课讲的"配置与环境分离"。
四、手动跑一次:命令行入口
数据齐了、Agent 有了,先手动试一发。cli.mjs:
// 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 的取值逻辑值得看:
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?因为命令行输入长句会被拆成多个参数:
node cli.mjs 无理由退货 要在几天内
# args = ["无理由退货", "要在几天内"] ← 被拆开了
# join(" ") → "无理由退货 要在几天内" ← 拼回来
不拼的话,"无理由退货"和"要在几天内"会被当成两个独立问题——这正是向量检索最怕的"语义被切碎"。
注意 DEFAULT_QUESTIONS 的设计:不给参数时有个默认问题,直接回车就能跑。 这是开发脚本的常见贴心做法——省掉每次手敲测试用例的功夫。
最后那行日志也很有用:
console.log(`命中 ${context.length} 条上下文`);
打出命中条数,是一个很轻量的健康检查。 如果某次查询命中 0 条,你会立刻意识到"检索环节出问题了"——不用等看到答案才猜。
五、让它亮起来:让 LangSmith 看见每一步
前面说 Agent 开发有"盲盒感"。怎么拆盲盒?
trigger-error.mjs 是今天最短但最妙的一个文件——它专门用来抛错。
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 是"自动接上"的
开头那两行注释,是这份文件最有价值的信息:
// 自动的根据.env langsmith 配置 去trace
// langchain,langgraph langsmith 打通的
两句话,讲清了 LangSmith 的接入方式——不用写一行上报代码。
你只要在 .env 里配好 LangSmith 的 key,LangChain / LangGraph 就会自动把执行过程上报。因为它俩和 LangSmith 本来就是一套体系里的东西。
这跟前面 harness 那节课手写 print 日志的做法完全不同:
| 做法 | 成本 | 能看到什么 |
|---|---|---|
| 手写 console.log | 每个节点都要加 | 只看到你想打的 |
| LangSmith 自动 trace | 零代码 | 每次调用的完整链路:输入输出、耗时、token、工具调用 |
"零代码接入"是它能成为标配的原因——如果观测要先写一堆埋点代码,大多数项目根本不会做。
而且这个图的写法也印证了"打通"二字——它就是最普通的 StateGraph,两节点一条线,没有任何为观测而写的特殊代码。
关键点二:故意造错,验证"错误路径也能被看见"
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 的界面上,你会看到一条轨迹:第一个节点绿着、第二个节点红着、状态栏显示错误。
这才是有价值的观测——不是"成功时能看",而是"失败时看得清失败在哪一步"。
注释里那句"不能正确的完成任务,没有返回值"——抛错的节点确实什么也不返回,这也解释了一个常见困惑:为什么我的图跑一半没输出了?因为节点抛异常,状态根本没往下传。
错误信息本身也写得很有心:
throw new Error("DemoError:节点内故意跑错:(trigger-error.mjs)");
错误信息里带了 DemoError: 前缀(方便搜索过滤)和文件名(一眼知道去哪找)。这是一条"好错误信息"的标准:说清是什么错、错在哪。
关键点三:错误处理的规范写法
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:上篇里我最喜欢那个"故意抛错"的文件——为了验证观测链路,专门造一个错误出来。这体现了一种很成熟的工程习惯:先确认"我能看见坏的情况",再去处理坏的情况。 否则线上真出问题的时候,你连问题在哪都找不到。