T
traeai
登录
返回首页
freeCodeCamp.org

Mastra vs LangChain: Building an AI Agent Pipeline and Analyzing the Data

8.5Score
Mastra vs LangChain: Building an AI Agent Pipeline and Analyzing the Data

TL;DR · AI 摘要

Mastra 和 LangChain 在构建 AI Agent 管道时各有优劣,Mastra 的工具开销较高,而 LangChain 提供更轻量的执行和更精细的控制。

核心要点

  • Mastra 的工具开销导致每运行增加数千个 token 和额外延迟。
  • LangChain 使用无类型节点和共享状态对象,执行更轻量。
  • 作者通过构建相同管道并测量性能,得出两者在不同场景下的适用性。

结构提纲

按章节快速跳转。

  1. 作者基于生产经验,对比 MastraLangChain 在构建 AI Agent 管道时的优缺点。

  2. Mastra 的工具开销高,而 LangChain 提供更轻量的执行和更精细的控制。

  3. 作者构建了相同的五步研究和合成管道,并在两个框架中进行性能测量。

  4. 作者使用 ConvexNext.js 构建了实时仪表盘,用于展示两个框架的运行决策和性能数据。

  5. Mastra 适合需要强类型和工作流控制的场景,而 LangChain 更适合轻量执行和精细控制的场景。

思维导图

用一张图看清主题之间的关系。

查看大纲文本(无障碍 / 无 JS 友好)
  • Mastra vs LangChain 管道对比
    • Mastra 的优缺点
      • 工具开销高
      • 强类型工作流
    • LangChain 的优缺点
      • 执行轻量
      • 精细控制
    • 性能测量
      • 相同管道构建
      • 实时仪表盘展示

金句 / Highlights

值得收藏与分享的关键句。

#AI Agent#Mastra#LangChain#前端#性能分析
打开原文

Mastra 与 LangChain:构建 AI Agent 管道并分析数据

2026 年 6 月 13 日

/

#langchain

Shola Jegede

一周前,我看到了这条推文:

我刚刚发布了 SupportMesh,这是一个基于 Mastra 构建的多租户 AI 支持平台,因此我对生产环境中的使用有一些看法。

我喜欢 .dowhile() 循环、类型化的步骤模式以及 createWorkflow 保持编排逻辑在一个地方的方式。我不喜欢的是令牌开销:每个代理步骤都会初始化 Mastra 的工具循环管理器,无论是否需要工具,而在一个四步管道中,这会增加几秒的额外延迟和每次运行数千个额外的令牌。

与此同时,我正在为另一个项目寻找 LangChain。这种方法与 Mastra 完全不同。而不是使用类型化的步骤合同的工作流,你构建一个有向图,其中节点是普通的异步函数,状态是一个单一的共享对象。

它的承诺是更轻量的执行和对每个模型调用中确切内容的更明确控制,这正是我看到 Mastra 的令牌开销后想要深入了解的内容。

因此,而不是根据文档和感觉选择其中一个,我在两者中构建了相同的管道并测量了一切。相同的五步研究和综合管道,两次,每个部分都有仪器:每个步骤的令牌数、每个步骤的延迟、每个阶段发送给 Claude 的确切提示、原始的 Tavily 搜索结果,以及一个实际产生多样化评分的生产级评估系统,而不是给所有内容一个 7 分。

然后,我在 Convex 和 Next.js 上构建了一个实时网络仪表板,这样你就可以自己运行任何主题,并看到这两个框架为了达到目标所做出的每一个决定。

目录

  • 前提条件
  • 我们使用的工具
  • 为什么选择这个管道
  • 项目结构
  • 构建 Mastra 管道 搜索工具 代理 writeCriticStep:为什么写和批评在同一步骤中进行 令牌捕获
  • 构建 LangChain 管道 状态注解 工厂模式 图和节点命名冲突 重试包装器
  • 所有东西都给出 7 分的批评者 生产级评估实际上是什么样子 从 Chain-of-Thought 输出中提取 JSON
  • 几乎部署的评估偏差
  • 实时仪表板 Convex 模式 火并忘记模式 订阅实时更新 重试后去重步骤 实时日志自动滚动
  • 数据实际上显示的内容
  • 自己尝试一下

前提条件

要跟随并亲自运行,你需要以下四样东西:

  • Node.js 22 或更高版本:管道包使用需要最新 Node 版本的现代 TypeScript 特性。
  • 一个 Anthropic API 密钥:你可以在 console.anthropic.com 获取一个。Claude Haiku 4.5 的价格足够低,运行这个基准 12 次只花费几美分。
  • 一个 Tavily API 密钥:你可以在 tavily.com 获取一个。免费层级每月提供 1,000 次搜索,这足以重复运行这个基准。
  • 一个 Convex 账户:你可以在 convex.dev 注册。免费层级涵盖这里的所有内容。

一旦你有了这些,本文最后的设置部分会详细说明每个部分的具体位置。

Mastra 是一个以 TypeScript 为主导的框架,用于构建 AI 驱动的应用程序和代理。其理念是您定义具有类型输入和输出模式的单个步骤,将它们链接成一个工作流程,而框架则处理它们之间的数据流。它对结构有特定的观点,这取决于您正在构建的内容,可能是一个功能,也可能是一个限制。

LangChain 是构建 LLM 应用程序最广泛使用的框架之一。它最初是用 Python 编写的,并且有一个 TypeScript 版本。

就代理编排而言,相关部分是 LangGraph,它是 LangChain 的基于图的执行层。与具有类型步骤合同的工作流程不同,您构建的是一个有向图:节点是普通的异步函数,状态是一个所有节点都从中读取和写入的单一共享对象,节点之间的流动由边控制。

Claude Haiku 4.5 是所有代理所使用的模型。它是 Anthropic 最快且最节省成本的模型,这使其成为当前的最佳选择。

Tavily 是专门为 AI 代理构建的网络搜索 API。与通用搜索 API 不同,它返回结构化的结果,包括相关性评分和可以直接传递到模型提示中的内容片段。免费层级足够慷慨,可以运行此基准测试而无需支付任何费用。

我在这里使用它是因为它有一个干净的 TypeScript SDK,它可以在 Mastra 工具和普通的 LangChain 节点中使用,而无需任何适配层,且搜索结果足够一致,使两个管道都能使用相同质量的输入。

Convex 是一个带有 React Hook useQuery 的实时数据库,每当底层数据发生变化时,它会自动重新渲染您的组件。无需轮询,无需设置 WebSocket,也无需手动同步状态。当两个管道在执行时写入步骤数据时,运行页面会自动更新。

Next.js 是用于仪表板的 Web 框架。使用 App Router、用于管道执行的 API 路由,以及在适当的地方使用服务器组件。

为什么选择这个管道

一个简单的比较不会告诉我任何有用的信息,因为框架之间的差异只在真正推动它们时才会显现。

我最终选择的管道有五个步骤:

code
Topic
  ↓
1. RESEARCH   (Tavily 网络搜索,5 个结果,附相关性评分)
2. ANALYSIS   (提取 5 个关键发现,3 个主题,1 个核心论点)
3. WRITE      (撰写结构化的约 400 字报告)
4. CRITIC     (对草稿进行评分,提供维度级别的反馈)
5. LOOP       (如果评分低于 7 分则进行修订,通过或达到 3 次迭代则输出)

我选择每个步骤是因为它们以不同的方式对框架施加压力。

研究步骤需要一个真正的工具调用,这正是 Mastra 的代理抽象最繁重的工作所在。分析步骤需要结构化的 JSON 输出,这测试了每个框架如何强制输出形状。撰写步骤通过纯提示工程强制执行严格的内容要求。批评步骤需要同时进行推理链的推理并生成结构化的 JSON,这实际上比听起来要困难得多。修订循环测试了这两个框架之间可能最根本的差异:每个框架如何表达条件迭代。

综合来看,这涵盖了您在生产环境中使用代理框架时实际构建的大部分内容:工具调用、结构化输出、多步骤编排、质量评估和反馈循环。

项目结构

所有内容都使用 npm workspaces 存放在一个单一的 monorepo 中,这意味着所有包共享根目录下的一个 node_modules,并且可以直接相互导入:

code
mastra-vs-langchain/
├── packages/
│   ├── mastra-pipeline/          # Mastra 实现
│   ├── langchain-pipeline/       # LangChain/LangGraph 实现
│   ├── web/                      # Next.js 16 App Router 仪表盘
│   └── shared/                   # 共享的 TypeScript 类型
├── convex/                       # 实时后端
└── package.json                  # 工作区根目录

共享包中最重要的部分是 PipelineCallbacks 接口,两个 pipeline 实现都必须满足这个接口。这是让仪表盘能够从任一框架接收实时事件的契约:步骤开始、步骤完成、令牌计数和 Tavily 结果,而无需了解 Mastra 或 LangChain 的具体信息:

code
// packages/shared/src/types.ts
export interface PipelineCallbacks {
  onPipelineStart: () => Promise<string>;
  onPipelineComplete: (id: string, data: PipelineCompleteData) => Promise<void>;
  onPipelineError: (id: string, error: string) => Promise<void>;
  step: {
    onStepStart: (stepName: string, iteration: number, input: string) => Promise<string>;
    onStepComplete: (stepId: string, data: StepCompleteData) => Promise<void>;
    onStepError: (stepId: string, error: string) => Promise<void>;
  };
}

每个 Convex 写入、实时日志条目和令牌计数都会通过这个接口。将来将新框架添加到基准测试中意味着只需实现这个接口并将其插入到 API 路由中,其他部分无需更改。

构建 Mastra Pipeline

如果你之前没有使用过 Mastra,核心的思维模型是这样的:你使用带有类型输入和输出模式的单个步骤,将它们链接在一起形成一个工作流,而 Mastra 会管理它们之间的数据流。

该框架对结构有明确的偏好,但这种结构能为整个 pipeline 提供类型安全性,并使编排逻辑易于阅读。

搜索工具

Mastra 工具是通过 createTool 创建的,它接受一个 Zod 输入模式和一个执行函数,该函数直接接收验证后的输入:

code
// packages/mastra-pipeline/src/tools/search.ts
import { createTool } from "@mastra/core/tools";
import { z } from "zod";
import { tavily } from "@tavily/core";

const client = tavily({ apiKey: process.env.TAVILY_API_KEY! });

export let lastTavilyCapture: { query: string; results: any[] } = {
  query: "",
  results: [],
};

export function resetTavilyCapture() {
  lastTavilyCapture = { query: "", results: [] };
}

export const searchTool = createTool({
  id: "web-search",
  description: "在某个主题上搜索网络信息",
  inputSchema: z.object({ query: z.string() }),
  execute: async ({ query }) => {
    lastTavilyCapture = { query, results: [] };
    const results = await client.search(query, {
      maxResults: 5,
      searchDepth: "basic",
    });
    lastTavilyCapture.results = results.results;
    return { results: results.results };
  },
});

lastTavilyCapture 模块级变量是一个针对真实约束的有意设计的变通方法。Mastra 的工具执行发生在代理的内部工具循环中,该循环位于工作流步骤的一层之下。

我需要捕获 Tavily 的查询和结果,以便在仪表板上显示,让用户可以看到每次运行的实际 URL 和相关性评分。但通过代理的工具执行上下文传递回调需要修改 Mastra 的内部实现。在模块作用域中捕获并在每次研究步骤开始时调用 resetTavilyCapture() 虽然不够优雅,但完全可靠,而且可以防止之前运行的过时数据影响当前运行。

代理

Mastra 管道中的每一步都作为单独的 Agent 实例运行。如果你刚开始使用 Mastra,有一点需要注意,那就是它需要一个显式的 id 字段,与 name 一起使用。如果你遗漏了它,TypeScript 会抛出一个令人困惑的错误,提示缺少必要的字段,但这个错误并没有指向实际的问题所在:

code
// packages/mastra-pipeline/src/agents/researcher.ts
export const researcherAgent = new Agent({
  name: "Researcher",
  id: "researcher",           // v1.41 中必需 - 容易被遗漏
  instructions: `你是一个研究代理。当给出一个主题时,使用 
  web-search 工具查找 5 个相关结果。以格式化字符串的形式返回所有原始搜索 
  结果,包括标题、URL 和内容片段。`,
  model: anthropic("claude-haiku-4-5"),
  tools: { searchTool },
});

writer 代理在其指令中直接携带了所有内容要求,而不是在单独的验证层中。这将约束放在一个显眼的地方,当评论者就草稿违反了哪些具体要求提供反馈时,这一点很重要:

code
// packages/mastra-pipeline/src/agents/writer.ts
export const writerAgent = new Agent({
  name: "Writer",
  id: "writer",
  instructions: `你是一个为技术受众写作的研究分析师。

严格要求:
- 开头句子必须陈述研究中的具体发现。
  永远不要以 "X 越来越重要" 开头。
- 每个段落只做一个论点。首先陈述它。
  用具体的证据支持它。
- 提到具体的工具、框架、公司、数字和日期。
- 结论必须做出具体的建议或预测。
  它不能重复引言。
- 目标长度:350-450 字。

禁止使用的短语:
"it is important to note", "it is worth noting",
"organizations must consider", "in conclusion", "in summary",
"as we look to the future", "rapidly evolving landscape",
任何如果替换主题后句子同样成立的句子`,
  model: anthropic("claude-haiku-4-5"),
});

writeCriticStep:为什么写和评论在同一步骤中

在实现 Mastra 时,我在这里做出了一项与大多数教程不同的架构决策,理解这一点是有价值的。

Mastra 的 .dowhile() 构造会循环一个步骤,直到满足某个条件。当你只需要重复一件事时,这很整洁。但修订循环需要两件事:一个写步骤,随后是一个评论步骤。你可以将它们合并为一个步骤,或者构建一个嵌套的工作流,其中内部工作流包含这两个步骤。

在这种情况下,嵌套的工作流增加了复杂性,但并没有带来任何好处,因此写和评论阶段共同存在于 writeCriticStep 中。该步骤首先运行写操作,然后立即对草稿进行评论,并返回一个包含草稿和评分的综合输出:

code
const writeCriticStep = createStep({
  id: "write-critic",
  inputSchema: z.object({
    topic: z.string(),
    research: z.string(),
    analysis: z.string(),
    keyFindings: z.array(z.string()),
    mainThemes: z.array(z.string()),
    centralArgument: z.string(),
    draft: z.string().optional(),       // 在第一次迭代后填充
    score: z.number().optional(),       // 在第一次迭代后填充
    feedback: z.string().optional(),    // 在第一次迭代后填充
    iterations: z.number().optional(),
  }),
  outputSchema: z.object({
    // ... 所有输入字段加上 draft, score, feedback, iterations
  }),
  execute: async ({ inputData }) => {
    const iteration = (inputData.iterations ?? 0) + 1;

    // 写作阶段
    let writerPrompt = `Topic: "\${inputData.topic}"\n\nResearch:\n\${inputData.research}\n\nAnalysis:\n\${inputData.analysis}`;
    if (inputData.feedback && inputData.draft) {
      // 在修改过程中,作者可以看到其之前的尝试和具体的反馈
      writerPrompt += `\n\nPrevious draft:\n\${inputData.draft}\n\nFeedback:\n\${inputData.feedback}`;
    }

    const writeStepId = await callbacks.step.onStepStart("write", iteration, writerPrompt.slice(0, 500));
    const writerResult = await writerAgent.generate(writerPrompt);
    const draft = writerResult.text;
    await callbacks.step.onStepComplete(writeStepId, { output: draft, /* token data */ });

    // 批评阶段:在写作阶段之后立即运行,使用相同的草稿
    const criticPrompt = `RESEARCH:\n\${inputData.research}\n\nANALYSIS:\n\${inputData.analysis}\n\nDRAFT:\n\${draft}`;
    const criticStepId = await callbacks.step.onStepStart("critic", iteration, draft.slice(0, 500));
    const criticResult = await criticAgent.generate(criticPrompt);
    const parsed = extractJson(criticResult.text);
    const score = parsed?.score ?? 4;
    const feedback = parsed?.feedback ?? "Score parsing failed";
    await callbacks.step.onStepComplete(criticStepId, { output: criticResult.text, criticScore: score });

    return { ...inputData, draft, score, feedback, iterations: iteration };
  },
});

.dowhile() 条件随后检查是否需要再次循环。它接收上一个 writeCriticStep 的输出作为 inputData,因此可以直接读取分数:

code
const workflow = createWorkflow({
  id: `research-pipeline-\${Date.now()}`,  // 时间戳可防止并发运行时的冲突
  inputSchema: z.object({ topic: z.string() }),
})
  .then(researchStep)
  .then(analysisStep)
  .dowhile(
    writeCriticStep,
    async ({ inputData }) => inputData.score < 7 && inputData.iterations < 3
  )
  .commit();

工作流 ID 中的 Date.now() 是因为 Mastra 工作流具有静态 ID 时,当两个运行同时开始时会发生冲突。添加时间戳可以为每个运行提供唯一的工作流实例。

Token Capture

在任何 agent.generate() 调用之后,使用数据会存在于结果对象上。由于 Mastra 版本之间的形状不同,因此检查可能的字段名称是安全的做法:

code
const inputTokens =
  (result as any).usage?.promptTokens ??
  (result as any).usage?.inputTokens ??
  0;
const outputTokens =
  (result as any).usage?.completionTokens ??
  (result as any).usage?.outputTokens ??
  0;

构建 LangChain 管道

LangChain/LangGraph 通过根本不同的思维模型解决相同的问题。

与 Mastra 提供的具有显式类型步骤合同的工作流不同,LangGraph 提供的是一个有向图。节点是普通的异步函数,状态是一个在图中流动的单一共享可变对象,执行顺序由边决定,而不是通过一系列 .then() 调用。

状态注解

在编写任何节点之前,使用 Annotation.Root 定义共享状态的形状。图中的每个节点都会从这个对象中读取并写入数据:

code
// packages/langchain-pipeline/src/graph/state.ts
export const PipelineState = Annotation.Root({
  topic: Annotation<string>(),
  research: Annotation<string>(),
  analysis: Annotation<string>(),
  draft: Annotation<string>(),
  score: Annotation<number>(),
  feedback: Annotation<string>(),
  iterations: Annotation<number>(),
  finalReport: Annotation<string>(),
  criticDimensions: Annotation<object>(),
});

对于来自 Mastra 的用户来说,数据流动方式的差异是显著的。在 Mastra 中,每个步骤声明它接收和返回的内容,框架在 TypeScript 层面强制执行这种合同。

在 LangGraph 中,任何节点都可以读取或写入共享状态中的任何字段。结构来源于图的拓扑结构,而不是类型系统,这意味着 Mastra 在编译时就能捕获数据流动的错误,而 LangGraph 则更容易在不修改每个步骤的模式的情况下向管道中添加新字段。

工厂模式

LangGraph 的节点是普通的异步函数,这正是它们轻量的原因:没有框架开销,无需初始化,只是你的代码调用模型。

挑战在于,我需要将回调函数和一个共享的令牌累加器传递给所有四个节点,而普通函数没有内置的机制来实现这一点。

解决方案是一个工厂函数,它将所有四个节点作为共享状态的闭包创建出来:

code
// packages/langchain-pipeline/src/graph/nodes.ts
export function createNodes(
  callbacks: PipelineCallbacks,
  acc: { inputTokens: number; outputTokens: number }
) {
  const tavilyClient = tavily({ apiKey: process.env.TAVILY_API_KEY! });

  async function researchNode(state: PipelineStateType): Promise<Partial<PipelineStateType>> {
    const stepId = await callbacks.step.onStepStart("research", 1, state.topic);
    const results = await tavilyClient.search(state.topic, { maxResults: 5, searchDepth: "basic" });
    const research = results.results
      .map((r, i) => `[\({i + 1}] \){r.title}\nURL: \({r.url}\nContent: \){r.content}`)
      .join("\n\n");
    await callbacks.step.onStepComplete(stepId, {
      output: research,
      promptSent: state.topic,
      timeMs: elapsed,
      inputTokens: 0,      // research step uses Tavily, not an LLM
      outputTokens: 0,
      model: "tavily-search",
      tavilyQuery: state.topic,
      tavilyResults: JSON.stringify(results.results),
    });
    return { research };
  }

  // analysisNode, writeNode, criticNode follow the same pattern

  return { researchNode, analysisNode, writeNode, criticNode };
}

请注意,研究节点返回的令牌数为 0,因为它直接调用 Tavily 而不涉及任何 LLM,这是在基准数据中出现的关键差异之一。每个后续节点都会直接将令牌数累加到共享的 acc 对象中:

code
const inputTokens = response.usage_metadata?.input_tokens ?? 0;
const outputTokens = response.usage_metadata?.output_tokens ?? 0;
acc.inputTokens += inputTokens;
acc.outputTokens += outputTokens;

LangChain 的 ChatAnthropic 将使用情况记录在 response.usage_metadata 中,该字段类型清晰,无需进行类型转换。

图与节点命名冲突

LangGraph 强制执行的一个容易被忽视的规则是:节点名称不能与状态注解键冲突。将节点命名为 "research" 会引发运行时错误,因为 state.research 已经作为状态通道存在,而错误信息并没有解释原因。将名称改为 "researcher" 和 "analyzer" 便可以解决问题:

code
export const pipeline = new StateGraph(PipelineState)
  .addNode("researcher", researchNode)   // 不使用 "research":与 state.research 冲突
  .addNode("analyzer", analysisNode)     // 不使用 "analysis":与 state.analysis 冲突
  .addNode("write", writeNode)
  .addNode("critic", criticNode)
  .addEdge(START, "researcher")
  .addEdge("researcher", "analyzer")
  .addEdge("analyzer", "write")
  .addEdge("write", "critic")
  .addConditionalEdges("critic", shouldRevise, {
    revise: "write",
    end: END,
  })
  .compile();

LangGraph 中的修订循环通过一个带有路由函数的条件边来表达:

code
function shouldRevise(state: PipelineStateType): string {
  if (state.score >= 7 || state.iterations >= 3) return "end";
  return "revise";
}

每次 critic 执行后,shouldRevise 函数都会运行,并返回 "revise" 以循环回 write 节点,或者返回 "end" 以退出图。这相当于 Mastra 的 .dowhile():相同的条件逻辑以图路由的方式表达,而不是以命名循环结构的方式表达。

重试包装器

当两个框架同时进行 HTTPS 请求时,都会遇到间歇性的 TLS 会话重用错误。错误信息看起来像这样:SSL routines:tls_get_more_records:decryption failed or bad record mac。一个带有线性退避机制的重试包装器可以处理这个问题:

code
async function retryOnFetch<T>(fn: () => Promise<T>, retries = 3): Promise<T> {
  for (let i = 0; i <= retries; i++) {
    try {
      return await fn();
    } catch (e: any) {
      const shouldRetry =
        e?.message?.includes("fetch") ||
        e?.message === "fetch failed" ||
        e?.message?.includes("SSL") ||
        e?.message?.includes("ECONNRESET") ||
        e?.message?.includes("other side closed") ||
        e?.cause?.code === "ECONNRESET";
      if (i < retries && shouldRetry) {
        await new Promise((r) => setTimeout(r, 1000 * (i + 1)));
        continue;
      }
      throw e;
    }
  }
  throw new Error("unreachable");
}

LangChain 节点中的每个 llm.invoke() 调用都被这个重试包装器包裹。在 Web 应用的 API 路由中,每个 Convex 调用也都有一个等效的 retryMutation 包装器,原因相同。

评分总是 7 分的批评者

当两个管道都运行时,我测试了几个主题。无论主题、框架还是迭代次数如何,每个评分都返回了 10 分中的 7 分。

实际上,这是一种被广泛记录的失败模式,称为 LLM-as-judge 偏见。当你要求语言模型在没有提供结构化标准和每个评分等级的明确锚点的情况下,从 1 到 10 之间进行评分时,它会倾向于选择 7 分。这是一个在社会上相对安全的答案:高到足以表明质量,低到足以显得公平,而且不需要任何真正的理由。模型没有进行区分的动机,因为提示中没有任何内容迫使它这样做。

我最初的批评者是这样的:

code
你是一个批判性的编辑。根据准确性、清晰度和深度对草稿进行 1-10 的评分。返回 { score, feedback }。

那句话就是整个提示,所以显然它给所有内容都打了7分。

实际生产级评估的样貌

我使用的解决方案来自于G-Eval论文,这也是DeepEval和RAGAS等工具所采用的方法。关键的见解是,你需要三个要素协同工作:评判者在给出任何评分之前必须逐步推理,被评分的维度之间必须相互独立,每个评分等级都必须有明确的描述,而不仅仅是“1是差的,10是完美的”。

因此,我围绕六个必须完成的步骤重建了批评者,只有在所有步骤都完成之后才会生成一个评分:

  • 声明审核:报告中的每个事实声明都会被分类为GROUNDED(由特定的搜索结果支持)、INFERRED(研究的合理延伸)、UNSUPPORTED(结果中没有依据)或HALLUCINATED(与结果相矛盾)。
  • 具体性审核:每个通用句子和每个被禁止的短语都会被明确标记。
  • 洞察审核:检查结论是否真的在重复引言内容之外有所补充。
  • 反事实检查:评判者必须指出至少一个读者在阅读此内容后会持有的具体信念,而这是他们仅从主题标题中不会持有的。如果无法识别出一个,洞察评分不能超过6分。
  • 维度评分:三个独立的评分,每个评分等级都有明确的锚点。
  • 最低评分规则:如果任何一个维度评分低于或等于4分,不管其他维度的评分如何,最终评分不能超过6分。

最低评分规则需要特别解释,因为它解决了一个真实存在的失败模式:如果没有这个规则,一份包含虚假事实的报告可能在来源忠实度上只得到2分,但如果具体性和洞察度足够高,其加权平均分仍可能通过。一个维度中的关键失败应该使报告不合格,而不是被稀释。

这就是完整的批评者提示,它通过nodes.ts中的一个常量在Mastra和LangChain之间共享:

code
const CRITIC_INSTRUCTIONS = `你是一位资深的研究编辑。
找出AI生成报告失败的具体方式。

步骤1:声明审核
对每个声明进行分类:[GROUNDED] [INFERRED] [UNSUPPORTED] [HALLUCINATED]

步骤2:具体性审核
列出那些通用的句子、使用了被禁止短语或没有可证伪声明的句子。被禁止的短语包括:"it is important to note"(重要的是要注意)、"organizations must consider"(组织必须考虑)、"rapidly evolving"(迅速发展)、"as we look to the future"(当我们展望未来)。

步骤3:洞察审核
结论是否添加了引言中没有的内容?

步骤3.5:反事实检查
指出一个读者在阅读此内容后会持有的具体信念,而这是他们仅从主题标题中不会持有的。如果你无法识别出一个,洞察评分不能超过6分。

步骤4:对每个维度进行评分

来源忠实度(40%权重):
5-6:声明准确,但追溯到一般主题知识,而不是这些具体结果
7:大多数声明可追溯,至少有一个来源被命名
8:所有主要声明都有依据,两个或更多被命名的来源带有具体细节
9-10:每个声明都追溯到一个被命名的来源,至少使用了一个统计数据

具体性(30%权重):
5-6:有一些具体的声明,但段落之间的分析较为通用
7:大部分是具体的,仍有少量填充内容
8:每个段落都可证伪,全文都有命名实体
9-10:如果你更换主题,没有句子能幸存下来

洞察力(权重 30%): 5-6:有一些综合,但结论在阅读前本可以写出 7:结论提出了一项基于证据的建议 8:指出了读者未曾考虑的权衡 9-10:一位高级工程师在阅读后会重新考虑架构决策

第 5 步:地板规则 如果任何维度评分低于或等于 4,最终评分不能超过 6。

第 6 步:计算 finalScore = round((fidelity * 0.40) + (specificity * 0.30) + (insight * 0.30))

只返回以下 JSON: { "fidelity": <1-10>, "fidelityReasoning": "<一句话>", "specificity": <1-10>, "specificityReasoning": "<一句话>", "insight": <1-10>, "insightReasoning": "<一句话>", "score": <加权最终评分>, "feedback": "<手术式:引用导致最低评分维度失败的具体句子,然后说明需要做出什么改变>" }`;

从 Chain-of-Thought 输出中提取 JSON

由于评论者现在在生成 JSON 之前写了几个段落的推理,JSON.parse(result.text) 抛出异常,因为响应不再是纯 JSON 了。在我发现并修复这个问题之前,每次解析失败都会静默地返回默认值 4,这意味着每个循环都对每个主题运行了完整的三次迭代。

修复方法是扫描文本以查找最后一个有效的 JSON 对象,从任何匹配项向后查找,因为 JSON 块总是在推理之后出现在末尾:

code
function extractJson(text: string): any {
  try { return JSON.parse(text.trim()); } catch {}

  const matches = text.match(/\{[\s\S]*\}/g);
  if (matches) {
    for (let i = matches.length - 1; i >= 0; i--) {
      try { return JSON.parse(matches[i]); } catch {}
    }
  }

  const fenced = text.match(/```(?:json)?\s*([\s\S]*?)```/);
  if (fenced) {
    try { return JSON.parse(fenced[1].trim()); } catch {}
  }

  return null;
}

几乎发布的评估偏见

在评论者重建后,一切开始正常运作:初稿评分在 4-6 之间,修订循环被触发,修订实际上比之前的尝试有所改进。

但一个明显的模式在技术主题中出现:Mastra 一直得分为 8-9,而 LangChain 一直得分为 6-7,每个主题都是如此。

查看评论者实际奖励的内容揭示了问题所在。源保真度占最终评分的 40%,它奖励那些引用了 Tavily 结果中具体命名来源的报告。Mastra 的报告充满了诸如“根据 Kore.ai 的分析”和“ArXiv 上关于协调多智能体系统的论文指出”的短语。LangChain 的报告提出了同样的观点,但没有将它们归因于具体来源。

原因在于上下文在每个管道中的流动方式。Mastra 的 Agent 类在其对话历史记录中通过工具循环携带了完整的 Tavily 内容(标题、URL、内容片段)。当写作者代理运行时,所有这些来源材料都在上下文中可用。

另一方面,LangChain 的写节点只接收了 state.analysis,这是从研究中提取的结构化 JSON:关键发现、主题和中心论点。当该 JSON 生成时,具体的来源细节已经被抽象掉了。写作者有结论,但没有引用。

两个管道都根据各自框架的惯用方式正确实现,但我没有意识到给它们的输入是不平等的。评估系统奖励了一个框架,因为它拥有更多的上下文,而不是因为它生成了更好的报告,而每个技术主题上一致的分数差距就是信号:真正的质量差异会因主题和草稿而异,但结构性差距每次都会以相同的方式表现出来。

修复方法是在 LangChain 的写入节点中进行一个更改:将 state.research(原始的 Tavily 结果)与 state.analysis 一起传递:

code
async function writeNode(state: PipelineStateType): Promise<Partial<PipelineStateType>> {
  const prompt = `You are a research analyst writing for a technical audience.

RESEARCH (raw search results -- cite specific sources by name):
${state.research}

ANALYSIS:
${state.analysis}
\${state.feedback ? `\nCRITIC FEEDBACK FROM PREVIOUS DRAFT:\n\${state.feedback}` : ""}

\${WRITER_INSTRUCTIONS}

Return ONLY the report text.`;

  const response = await retryOnFetch(() => llm.invoke(prompt));
  return { draft: response.content as string, iterations: (state.iterations ?? 0) + 1 };
}

当两个写作者接收到相同的原始材料后,质量评分现在反映了实际的写作质量。如果你的评估系统在多次运行中始终偏爱其中一个选项,首先需要检查的是两个选项的输入是否相等。结构性差距会产生一致的结果,而真正的质量差异会因主题和草稿质量而变化。

实时仪表板

在终端中运行管道适用于你自己的比较,但它无法扩展为其他人可以使用的基准。仪表板需要两个管道并行运行,每一步的执行过程都可见,每一步的完整提示和响应都可以展开,Tavily 结果带有相关性评分条,令牌计数,实时滚动日志,并且所有内容都按类别保存并可浏览。

Convex 模式

Convex 特别选择用于实时功能:其 React 中的 useQuery 钩子会订阅数据库查询,并在底层数据变化时自动重新渲染,无需你在端进行轮询或 WebSocket 管理。

模式在三个粒度级别上存储每次运行:

code
steps: defineTable({
  runId: v.id("runs"),
  pipelineResultId: v.id("pipelineResults"),
  framework: v.union(v.literal("mastra"), v.literal("langchain")),
  stepName: v.union(
    v.literal("research"), v.literal("analysis"),
    v.literal("write"), v.literal("critic")
  ),
  iterationNumber: v.number(),
  status: v.union(v.literal("running"), v.literal("complete"), v.literal("error")),
  promptSent: v.optional(v.string()),
  output: v.optional(v.string()),
  timeMs: v.optional(v.number()),
  inputTokens: v.optional(v.number()),
  outputTokens: v.optional(v.number()),
  model: v.optional(v.string()),
  tavilyQuery: v.optional(v.string()),
  tavilyResults: v.optional(v.string()),
  criticScore: v.optional(v.number()),
  criticFeedback: v.optional(v.string()),
  criticDimensions: v.optional(v.object({
    fidelity: v.number(),
    specificity: v.number(),
    insight: v.number(),
    fidelityReasoning: v.string(),
    specificityReasoning: v.string(),
    insightReasoning: v.string(),
  })),
}).index("by_pipeline_result", ["pipelineResultId"]),

criticDimensions 字段存储完整的 G-Eval 分析结果,以便仪表板可以使用彩色条形图显示各个维度的评分,并展示每个维度的推理文本。

火与忘模式

在 Next.js API 路由中最重要的决定是在任何流水线完成之前返回 runId。如果你先等待两个流水线完成,浏览器将需要等待 30 到 60 秒才能导航到运行页面,这样实时更新的全部意义就丧失了。

ts
const activeTasks = new Map<string, Promise<void>>();

export async function POST(req: NextRequest) {
  const { topic, category } = await req.json();

  // 同步创建 Convex 记录(这些操作非常快)
  const runId = await retryMutation(() =>
    fetchMutation(api.runs.createRun, { topic, category, status: "running" })
  );
  const mastraResultId = await retryMutation(() =>
    fetchMutation(api.pipelineResults.createPipelineResult, {
      runId, framework: "mastra", status: "running", iterations: 0,
    })
  );
  const langchainResultId = await retryMutation(() =>
    fetchMutation(api.pipelineResults.createPipelineResult, {
      runId, framework: "langchain", status: "running", iterations: 0,
    })
  );

  // 不等待它们,启动两个流水线
  const task = Promise.allSettled([
    withRetry(() => runMastraPipeline(topic, buildCallbacks(runId, mastraResultId, "mastra"))),
    withRetry(() => runLangChainPipeline(topic, buildCallbacks(runId, langchainResultId, "langchain"))),
  ]).then(async () => {
    await retryMutation(() =>
      fetchMutation(api.runs.updateRunStatus, { runId, status: "complete" })
    );
    activeTasks.delete(runId as string);
  });

  // 在 Map 中保存引用,防止 Node.js 垃圾回收该 promise
  activeTasks.set(runId as string, task);
  return NextResponse.json({ runId });   // 立即返回
}

在 Vercel 上,这种模式仍然会失败,因为无服务器函数在路由处理程序返回后会终止,从而杀死任何后台 promise。解决方法是使用 @vercel/functions 中的 waitUntil,它告诉 Vercel 在 promise 解决之前保持执行上下文存活:

ts
import { waitUntil } from "@vercel/functions";

waitUntil(task);
return NextResponse.json({ runId });

订阅实时更新

在运行页面上,三个 Convex 查询同时运行:运行本身、流水线结果,以及每个流水线结果的步骤。

这里的 "skip" 信号非常重要:它告诉 Convex 在有实际参数可用之前保持订阅打开,而不执行查询。这可以防止在流水线结果记录创建之前步骤查询触发的竞态条件:

ts
const mastraSteps = useQuery(
  api.steps.getStepsForPipelineResult,
  mastraResult ? { pipelineResultId: mastraResult._id } : "skip"
);

重试后去重步骤

当流水线因 TLS 错误失败并从头开始重试时,失败尝试的步骤记录会与成功尝试的记录一起保留在 Convex 中。UI 会同时渲染两者,导致研究卡片与其余步骤之间出现明显的空白。

解决方法是按 stepName + iterationNumber 对步骤进行分组,并保留每个步骤的最佳版本:

code
const stepMap = new Map<string, Step>();
[...steps]
  .sort((a, b) => (a._creationTime ?? 0) - (b._creationTime ?? 0))
  .forEach((s) => {
    const key = `\${s.stepName}-\${s.iterationNumber}`;
    const existing = stepMap.get(key);
    if (!existing) { stepMap.set(key, s); return; }
    if (s.status === "complete") { stepMap.set(key, s); return; }
    if (existing.status !== "complete") { stepMap.set(key, s); }
  });

实时日志自动滚动

在 Convex 中,日志条目作为数组追加到流水线结果文档中,并通过附加到底部空 div 的 ref 实现面板的自动滚动:

code
function LiveLogPanel({ logs }: { logs?: LogEntry[] }) {
  const endRef = useRef<HTMLDivElement>(null);

  useEffect(() => {
    endRef.current?.scrollIntoView({ behavior: "smooth" });
  }, [logs?.length]);

  return (
    <div className="max-h-52 overflow-y-auto font-mono text-xs">
      {logs?.map((entry, i) => (
        <div key={i} className="flex gap-2">
          <span className="text-[#484f58]">[{fmtTs(entry.timestamp)}]</span>
          <span className={`font-bold w-14 ${tagColor(entry.tag)}`}>{entry.tag}</span>
          <span className="text-[#c9d1d9]">{entry.message}</span>
        </div>
      ))}
      <div ref={endRef} />
    </div>
  );
}

副作用的依赖项是 logs?.length,因此每当从 Convex 接收到新的日志条目时,滚动就会触发。

数据实际展示的内容

速度:在每次运行中,LangChain 比 Mastra 快 25-45%。在较短的主题上,差距缩小到 7-8 秒,但从未逆转。

我认为造成这种现象的原因是结构性的。Mastra 的 Agent 类在每一步都会初始化其工具循环管理器,即使没有调用任何工具。这意味着内部对话历史、工具模式和重试基础设施在实际模型调用之前都作为开销被设置。

在四步流水线中,每一步的 2-5 秒累积起来。LangGraph 的节点是普通的异步函数,因此你的代码可以直接运行,你与模型之间没有框架初始化。

令牌:Mastra 使用的令牌是 LangChain 的 1.5-2.5 倍。研究步骤本身占据了大部分差距,因为 LangChain 的研究节点直接调用 Tavily 而不调用任何 LLM。

在更典型的话题上,Mastra 约使用 6,200 个令牌,而 LangChain 约使用 3,900 个。差距随着 Tavily 返回的内容量而变化,因为这些内容会流入 Mastra 的代理对话历史记录中的每一步。

质量:在修正评估偏差后,评分因主题而异,而不是因框架而异。当 Tavily 的结果具体且丰富时,两者都能生成高分报告。两者在模糊或传记类主题上都遇到困难,因为搜索结果是通用的。

初稿得分为 7 或 8 表示研究工作扎实,作者提出了具体且有依据的主张。得分为 4 或 5 表示研究返回的结果内容较少,作者默认使用了通用的观察,并且修订循环会一直运行,直到初稿得到改进或达到迭代限制。

权衡:Mastra 在框架中处理了编排的复杂性,因此你不需要处理。你可以编写 .dowhile() 而不是条件边,使用类型化的步骤模式而不是共享的可变状态对象,框架会管理对话历史和工具执行。代价是每一步都会产生一致的令牌和延迟开销。

LangChain 为你提供了图执行引擎,其余部分则交由你来处理:需要编写更明确的连接逻辑,但执行过程更精简,对每个模型调用中进入的每个 token 都有精确的控制。

自己尝试一下

实时演示可在 mastra-vs-langchain.vercel.app 上查看,本次比较的完整源代码可在 github.com/sholajegede/mastra-vs-langchain 上找到。如果对你有帮助,不妨给它点个星。

code
git clone https://github.com/sholajegede/mastra-vs-langchain.git
cd mastra-vs-langchain
npm install
cp .env.example .env
# 添加 ANTHROPIC_API_KEY 和 TAVILY_API_KEY
npx convex dev   # 终端 1
npm run web      # 终端 2

打开 localhost:3000,输入一个主题,选择一个类别,然后运行两个应用。每一步都会在发生时显示,每个 token 都会被计数,历史页面会按类别存储所有之前的运行记录。

如果你想通过添加 CrewAI、CopilotKit 或任何其他框架来进一步扩展这个比较,你只需要实现 packages/shared 中的 PipelineCallbacks 接口这一项合同。

如果本教程对你有帮助,不妨与可能受益的其他人分享。我非常感激你的想法。你可以在 X 上提到我 @wani_shola,或者在 LinkedIn 上与我联系。

我喜欢构建和撰写开发者真正想使用的工具。作为一名开发者关系工程师和倡导者,我工作在代码与社区的交汇点,这里是优秀产品与构建它们的人相遇的地方。我撰写的文章已触及成千上万的开发者,为全球使用的开源工具做出了贡献,并与 freeCodeCamp、Forem(dev.to)、Convex 和 Kinde 等社区进行了合作。我非常重视开发者的体验,我相信最好的工具是那些让复杂问题变得简单、有趣且极其有用的工具。

如果你读到了这里,请感谢作者,以表明你对他们的关心。说声谢谢

免费学习编程。freeCodeCamp 的开源课程已帮助超过 40,000 人成为开发者。立即开始学习

ADVERTISEMENT

AI 可能会生成不准确的信息,请核实重要内容