前言
在前两篇文章中,我分别使用 Mastra 实现了 RAG 知识库和 Agent Memory。
- 从零构建 Mastra RAG MVP:DeepSeek、SiliconFlow 与 PostgreSQL/pgvector 实战
- 从 RAG 到有记忆的 Agent:Mastra Memory 与 PostgreSQL 实战
RAG 解决的是 Agent 如何获取外部知识,Memory 解决的是 Agent 如何保存和使用上下文。
那么接下来的问题是:如果一项任务需要研究、分析、执行和审核,能不能让多个 Agent 分工协作?
为了回答这个问题,我决定使用 Mastra 构建一个技术文章生成系统。
输入一个主题,例如:
Go 后端为什么应该从 MD5 迁移到 Argon2id?
系统需要自动完成资料检索、技术分析、文章编写和质量审核。如果 Reviewer 认为文章存在问题,Writer 应该根据反馈修改文章,而不是简单地重新生成一遍。
这次实验的目标不是构建一个完整的内容生产平台,而是理解 Mastra 多 Agent 协作的基本原理,以及实际开发中会遇到哪些工程问题。
一、系统架构
我将文章生成过程拆分为四个业务 Agent。
整体执行过程如下:
flowchart TD
A["输入文章主题"] --> B["Research Agent"]
B --> C["Research Formatter"]
C --> D["Analysis Agent"]
D --> E["Writer Agent"]
E --> F["Reviewer Agent"]
F --> G{"审核决策"}
G -->|approve| H["完成文章"]
G -->|revise| I["Writer 修改文章"]
I --> F
G -->|research| J["Research 补充资料"]
J --> D
其中,Research Formatter 是一个辅助格式化环节:Research Agent 可以先使用工具生成研究报告,再由 Formatter 将报告转换为符合 Zod Schema 的 JSON。
系统的关键不在于 Agent 数量,而在于 每个 Agent 的输入、输出和决策边界是否清晰。
尤其是 Reviewer,它不应该只是返回一个评分,而应该决定下一步执行什么操作。
二、使用 Zod 定义 Agent 之间的数据契约
多 Agent 协作首先需要解决数据交换问题。
如果 Research Agent 输出一大段自然语言,Analysis Agent 再自行理解,Writer Agent 又重新解释一次,那么信息就可能在不同 Agent 之间逐渐丢失或发生偏差。
因此,我决定使用 Zod 为各个阶段定义明确的数据结构。
2.1 ResearchResult
Research Agent 的任务不是写文章,而是提供事实、来源和证据缺口。
import { z } from "zod";
export const researchSchema = z.object({
topic: z.string(),
findings: z.array(
z.object({
claim: z.string(),
sourceId: z.string(),
confidence: z.enum(["high", "medium", "low"]),
}),
),
sources: z.array(
z.object({
id: z.string(),
title: z.string(),
url: z.string().nullable(),
verified: z.boolean(),
}),
),
gaps: z.array(z.string()),
summary: z.string(),
});
这里有几个值得注意的字段。
sourceId 用于建立事实与资料来源之间的关系。confidence 表示研究结果的置信程度,但它并不等于事实已经得到独立验证。verified 用于记录来源是否已经实际检索和阅读。gaps 则记录当前资料中尚未解决的问题。
我不希望 Agent 在证据不足时自行补充事实,因此将证据缺口作为 ResearchResult 的一部分。
2.2 AnalysisResult
Analysis Agent 接收 ResearchResult,并将研究结果组织成文章结构。
export const analysisSchema = z.object({
topic: z.string(),
keyPoints: z.array(
z.object({
point: z.string(),
sourceIds: z.array(z.string()),
importance: z.enum(["high", "medium", "low"]),
}),
),
outline: z.array(
z.object({
heading: z.string(),
description: z.string(),
sourceIds: z.array(z.string()),
}),
),
risks: z.array(
z.object({
description: z.string(),
sourceIds: z.array(z.string()),
}),
),
missingEvidence: z.array(z.string()),
needsMoreResearch: z.boolean(),
summary: z.string(),
});
与普通文章大纲不同,这里的每个关键论点和章节都可以关联来源 ID。这让 Writer 有机会判断某个结论是否具备研究依据,而不是仅根据模型已有知识生成内容。
2.3 DraftResult
Writer Agent 负责将研究结果和文章大纲转换为 Markdown。
export const draftSchema = z.object({
title: z.string(),
description: z.string(),
tags: z.array(z.string()),
categories: z.array(z.string()),
content: z.string(),
usedSourceIds: z.array(z.string()),
notes: z.array(z.string()),
});
content 保存 Markdown 正文。usedSourceIds 保存文章实际引用的来源 ID。notes 用于记录需要 Reviewer 关注的编辑说明,例如证据限制、缺少性能数据等。
这里有一个重要原则:Writer 可以组织和解释已有证据,但不应该为了让文章更完整而编造证据。
2.4 ReviewResult
Reviewer 的输出决定 Workflow 的下一步执行路径。
import { z } from "zod";
export const reviewIssueSchema = z.object({
severity: z.enum(["high", "medium", "low"]),
description: z.string(),
suggestedFix: z.string(),
});
export const reviewSchema = z.object({
decision: z.enum(["approve", "revise", "research"]),
score: z.number().min(0).max(100).nullable(),
issues: z.array(reviewIssueSchema),
feedback: z.string(),
});
这里最重要的字段是 decision:approve 表示文章通过审核;revise 表示可以利用已有资料修改文章;research 表示缺少必要证据,需要补充研究。
为了避免不合理的审核结果,我还使用 superRefine() 增加业务规则:
export const validatedReviewSchema = reviewSchema.superRefine(
(review, ctx) => {
if (review.decision !== "approve" && review.issues.length === 0) {
ctx.addIssue({
code: "custom",
path: ["issues"],
message: "revise/research decisions must include actionable issues",
});
}
if (
review.decision === "approve" &&
review.issues.some((issue) => issue.severity === "high")
) {
ctx.addIssue({
code: "custom",
path: ["decision"],
message: "Cannot approve a draft with high-severity issues",
});
}
},
);
需要注意的是,传给 LLM 的是基础 reviewSchema,而业务校验使用 validatedReviewSchema。这样可以把 JSON 结构约束与业务规则分开处理。
三、Research Agent:让知识库成为研究资料来源
Research Agent 使用之前 RAG 项目中的知识检索工具。
import { Agent } from "@mastra/core/agent";
import { knowledgeSearchTool } from "../tools/knowledge-search.tool.js";
import { workflowModel } from "../lib/openai.js";
export const researchAgent = new Agent({
id: "research-agent",
name: "Research Agent",
model: workflowModel,
tools: { knowledgeSearchTool },
instructions: `
You are a technical research specialist.
Responsibilities:
1. Search the knowledge base for relevant information.
2. Extract factual findings.
3. Record source references.
4. Identify missing evidence.
Rules:
- Always search before answering.
- Never invent sources.
- Clearly distinguish facts and assumptions.
- If evidence is insufficient, say so.
- Do not write the final article.
Respond in Chinese.
`,
});
检索工具调用之前实现的 searchKnowledge():
const results = await searchKnowledge(query, { topK: 5 });
其底层仍然是之前实现的 PostgreSQL/pgvector 检索方案。
这里我遇到的第一个设计问题是:Research Agent 不仅需要调用工具,还需要返回结构化数据。
最初我尝试让它直接完成这两件事情,但在模型兼容性和输出稳定性方面遇到了问题。最后采用了一个更容易调试的方式:将研究与格式化分开。
export async function conductResearch(prompt: string) {
const report = await researchAgent.generate(prompt, { maxSteps: 8 });
if (!report.text.trim()) {
throw new Error("Research returned an empty report");
}
const formatted = await researchFormatterAgent.generate(
`Convert the research report into JSON.\n\nResearch report:\n${report.text}`,
{ structuredOutput: { schema: researchSchema } },
);
return researchSchema.parse(formatted.object);
}
以上省略了实际项目中的完整格式要求和来源一致性校验。
这样设计并不代表必须为每个格式化任务创建一个独立 Agent。它只是我在这个 POC 中选择的一种实现方式。如果以后需要降低成本,也可以考虑直接使用一次普通的结构化模型调用。
四、使用 Mastra Workflow 编排多个 Agent
单独调用四个 Agent 并不复杂。真正复杂的是:如何让 Reviewer 的输出决定后续流程,并让 Writer 使用上一轮审核结果继续修改文章?
我使用 Mastra 的 createWorkflow() 和 createStep() 实现这个过程。
4.1 初始化阶段
初始化阶段依次执行:
const initializeStep = createStep({
id: "initialize-article",
inputSchema,
outputSchema: stateSchema,
execute: async ({ inputData }) => {
const { topic } = inputData;
console.log("[Research] Starting...");
const research = await conductResearch(`Research this topic: ${topic}`);
console.log("[Analysis] Starting...");
const analysisResponse = await analysisAgent.generate(
`Analyze this research:\n${JSON.stringify(research)}`,
{ structuredOutput: { schema: analysisSchema } },
);
const analysis = analysisSchema.parse(analysisResponse.object);
console.log("[Writer] Creating initial draft...");
const writerResponse = await writerAgent.generate(
`Write an article using:\n\nResearch:\n${JSON.stringify(research)}\n\nAnalysis:\n${JSON.stringify(analysis)}`,
{ structuredOutput: { schema: draftSchema } },
);
const draft = draftSchema.parse(writerResponse.object);
validateCitations(research, draft);
return {
topic,
research,
analysis,
draft,
reviewHistory: [],
reviewCount: 0,
researchRounds: 0,
maxReviews: inputData.maxReviews,
maxResearchRounds: inputData.maxResearchRounds,
status: "pending" as const,
};
},
});
这段代码展示了核心结构,完整实现中还包含更严格的 Prompt 和错误处理。
这里的 stateSchema 非常重要。它保存研究结果、分析结果、当前草稿、审核历史以及循环计数器。下一轮审核需要读取的是最新草稿,而不是第一次生成的文章。
4.2 Reviewer 决策
审核阶段首先调用 Reviewer:
const response = await reviewerAgent.generate(prompt, {
structuredOutput: { schema: reviewSchema },
});
const review = validatedReviewSchema.parse(response.object);
然后根据审核结果决定下一步操作。
if (review.decision === "approve") {
return { ...base, status: "approved" as const };
}
if (nextCount >= maxReviews) {
return { ...base, status: "max_reviews_reached" as const };
}
if (review.decision === "research") {
// 补充研究,再次分析并重写文章
}
// revise:使用现有证据修改文章
revise 分支会将当前文章和 Reviewer 的反馈一起传给 Writer:
const writerResponse = await writerAgent.generate(
`Revise this Chinese engineering article.
Current DraftResult:
${JSON.stringify(draft)}
Reviewer:
${JSON.stringify(review)}
Address each issue using existing evidence.
Do not invent new source IDs or facts.
Return a complete DraftResult.`,
{ structuredOutput: { schema: draftSchema } },
);
这与简单的“重新生成文章”不同。Writer 能够读取上一轮文章和具体修改建议,围绕现有内容继续工作。
4.3 使用 dountil 实现审核循环
最后使用 .dountil() 将审核步骤连接起来:
export const fullArticleWorkflow = createWorkflow({
id: "full-article-workflow",
inputSchema,
outputSchema: stateSchema,
})
.then(initializeStep)
.dountil(
reviewLoopStep,
async ({ inputData }) => inputData.status !== "pending",
)
.then(finalizeStep)
.commit();
只要状态仍然是 pending,Workflow 就继续执行审核步骤。如果 Reviewer 返回 approve,或者达到最大审核次数,就退出循环。
我还设置了 maxResearchRounds,避免系统无限补充资料。
这里需要强调:research 分支虽然已经实现,但在本文展示的成功测试中没有被触发,因此不能仅凭这次运行就认为补充研究路径已经得到完整验证。
五、实际开发中遇到的问题
这一部分是整个项目最花时间的地方。
一开始我认为最困难的是多 Agent 的工作流编排。实际开发后才发现,结构化输出的稳定性反而是更大的问题。
5.1 DeepSeek 返回合法 JSON,但不符合 Schema
最初使用的是 DeepSeek,通过 OpenAI Compatible API 接入 Mastra。
import { createOpenAICompatible } from "@ai-sdk/openai-compatible";
export const deepseek = createOpenAICompatible({
name: "deepseek",
baseURL: "https://api.deepseek.com/v1",
apiKey: process.env.DEEPSEEK_API_KEY!,
});
首先遇到的是 JSON 模式的请求限制:
Prompt must contain the word 'json' in some form
to use 'response_format' of type 'json_object'.
这个问题相对容易解决,只需要在 Prompt 中明确要求返回 JSON。但之后出现的问题更加麻烦。
例如 Analysis Agent 应该返回:
{
"keyPoints": [
{
"point": "MD5 不适合密码存储",
"sourceIds": ["S1"],
"importance": "high"
}
]
}
实际却可能返回:
{
"keyPoints": ["MD5 不适合密码存储"]
}
从 JSON 语法来看,两者都是合法的。但从 Zod Schema 来看,第二种显然不符合要求。
此外,我还遇到过:
- risks 返回字符串数组,而不是对象数组。
- missingEvidence 被替换成 evidenceGaps。
- 必填的 summary 字段缺失。
- 字段类型和嵌套对象结构不一致。
最终导致:
STRUCTURED_OUTPUT_SCHEMA_VALIDATION_FAILED
这些问题在单个 Agent 中可能只是一次重试。但在多 Agent Workflow 中,某个阶段输出不符合 Schema,就会影响整个执行链。
为了兼容这些输出,我一度使用了大量 z.preprocess(),尝试将模型返回的数据转换成预期结构。
const risksSchema = z.preprocess(
(value) => {
if (!Array.isArray(value)) return value;
return value.map((item) => {
if (typeof item === "string") {
return { description: item, sourceIds: [] };
}
return item;
});
},
z.array(riskSchema),
);
这种方式可以处理部分格式差异,但也会带来额外复杂度。
我逐渐意识到:如果需要编写大量代码来修正 LLM 的输出格式,那么模型与任务之间可能并不匹配。
5.2 切换 GPT-4.1 mini
为了提高结构化输出的稳定性,我最终将四个业务 Agent 切换到 GPT-4.1 mini。
项目使用了自定义 OpenAI 兼容 API 地址:
import type { MastraModelConfig } from "@mastra/core/llm";
export const workflowModel: MastraModelConfig = {
id: "bianxie/gpt-4.1-mini",
url: "https://api.bianxie.ai/v1",
apiKey: process.env.OPENAI_API_KEY!,
api: "chat",
};
这里使用的是第三方 OpenAI 兼容代理,而不是直接连接 OpenAI 官方 API。实际部署时需要自行评估代理服务的可靠性、隐私和兼容性。
切换过程中还遇到了 AI SDK 与 Mastra 的类型兼容问题:
Type 'LanguageModelV4' is not assignable to
type 'MastraLanguageModelV4'.
原因是直接创建的 AI SDK 模型对象与当前 Mastra 所接受的模型接口存在类型差异。最终使用 Mastra 支持的模型配置对象,避免直接传入不兼容的模型实例。
5.3 OpenAI Strict JSON Schema 不接受 optional 字段
切换模型后,前三个 Agent 已经可以正常执行。但 Reviewer 又遇到了一个新的问题:
Invalid schema for response_format 'response':
'required' is required to be supplied
and to be an array including every key
in properties. Missing 'score'.
当时的 Schema 是:
score: z.number().min(0).max(100).optional(),
普通 Zod 校验允许字段不存在。但 OpenAI Strict Structured Outputs 要求对象的所有属性都出现在 required 中。
解决方法是将 optional() 改成 nullable():
score: z.number().min(0).max(100).nullable(),
这样 score 仍然必须返回,但允许其值为 null。
这次问题让我更加明确:Zod Schema 能够通过 TypeScript 类型检查,并不代表它一定能够直接作为 LLM 的 Strict JSON Schema 使用。
因此,我将结构化输出 Schema 与业务规则校验分离,避免把所有约束都塞进模型输出阶段。
5.4 引用校验也会导致 Workflow 失败
另一个问题出现在 Writer 生成文章之后。当时的错误是:
Declared but unused source: S1
Writer 声明使用了 S1,但校验程序没有在正文中识别出对应引用。最初的实现只支持 [S1] 这种格式。然而,模型可能生成 [S1, S2]、(S1) 等不同形式。
因此,我调整了引用提取逻辑:
const citationGroups = [
...draft.content.matchAll(
/[\[((]((?:S\d+)(?:\s*[,,、;;]\s*S\d+)*)[\]))]/g,
),
];
const cited = new Set(
citationGroups.flatMap((match) => match[1].match(/S\d+/g) ?? []),
);
随后检查:声明的来源是否存在于 ResearchResult;正文引用的来源是否存在;正文引用是否包含在 usedSourceIds 中。
对于声明但未使用的来源,POC 阶段只输出警告,而不是直接终止 Workflow。
需要注意,这种校验只能保证引用 ID 的基本一致性。它无法证明某个技术结论真的受到对应来源支持,更无法保证来源本身真实可靠。这仍然需要更深入的证据验证。
六、最终运行结果
完成上述调整后,我再次运行完整 Workflow:
npx tsx src/scripts/test-full-article-workflow.ts \
"Go 后端为什么应该从 MD5 迁移到 Argon2id?"
这次终于成功。
[Research] Starting...
[Analysis] Starting...
[Writer] Creating initial draft...
[Citation] Validation passed
{ declared: [ 'S1' ], cited: [ 'S1' ] }
[Review 1/3] revise
[Writer] Revising from feedback...
[Citation] Validation passed
{ declared: [ 'S1' ], cited: [ 'S1' ] }
[
本文由 AI图鉴自动抓取并翻译,版权归原作者所有;如需查看原文请点下方链接。
Foreword
In the two previous articles, I used Mastra to achieve the RAG knowledge base and Agent Memoory, respectively.
- Build from zero Mastra RAG MVP: DeepSeek, SiliconFlow and PostgreSQL/pgvector
- From RAG to memory Agent: Mastra Memory versus PostgreSQL
RAG solves how Agent acquires external knowledge, and Memoory solves how Agent saves and uses context.
The next question, then, is: If a mission needs to be studied, analysed, executed and reviewed, can it be possible to have multiple Agents working together?
To answer that question, I decided to use Mastra to build a technical article generation system.
Enter a theme, for example:
Why should the Go backend move from MD5 to Argon2id?
The system requires automatic information retrieval, technical analysis, article writing and quality clearance. If Reviewer believes there is a problem with the article, Writer should modify the article based on feedback, rather than simply recreate it.
The objective of the experiment was not to build a full content production platform, but to understand the rationale for the Mastra-Agent collaboration and what engineering problems would be encountered in the actual development.
System architecture
I've got the article generation process split in four operations, Agent.
The overall implementation process is as follows:
flowchart TD
A ["Input article theme"] -- >B ["Research Agent"]
B --> C["Research Formatter"]
C --> D["Analysis Agent"]
D --> E["Writer Agent"]
E --> F["Reviewer Agent"]
F-G's "Audit Decision"
G->approve H.
G->revise I ["Writer modified article"]
I --> F
"G--"Research" J.
J --> D
Of these, ResearchFormatter is a supporting formatting link: ResearchAgent can use tools to generate studies, and Formatter can convert them to JSON that matches Zod Schema.
The key to the system is not the number of Agents, but the clarity of the input, output and decision-making boundaries of each Agent.
In particular, Reviewer, it should not simply return to a rating, but rather decide what next step does.
Use Zod to define a data compact between Agent
MultiAgent collaboration needs to address data exchange first.
If ResearchAgent output a large part of the natural language, AnalysisAgent understands it and WriterAgent reinterprets it again, then the information may be lost or distorted between different Agents.
So I decided to use Zod to define a clear data structure for each phase.
2.1 ResearchResult
Research Agent ' s mission is not to write articles, but to provide gaps in facts, sources and evidence.
import { z } from "zod";
export const researchSchema = z.object({
topic: z.string(),
findings: z.array(
z.object({
claim: z.string(),
sourceId: z.string(),
confidence: z.enum(["high", "medium", "low"]),
}),
),
sources: z.array(
z.object({
id: z.string(),
title: z.string(),
url: z.string().nullable(),
verified: z.boolean(),
}),
),
gaps: z.array(z.string()),
summary: z.string(),
});
There are several interesting fields here.
SourceId is used to establish a relationship between facts and sources. Confidence indicates the level of confidence in the results of the study, but it does not mean that the facts have been independently verified. Verified is used to record whether the source has been physically retrieved and read. Gaps records the outstanding issues in the current information.
I do not want Agent to add to the facts when there is insufficient evidence, so the evidence gap is part of ResearchResult.
2.2 AnalysisResult
Analysis Agent received ResearchResult and organized the study into an article structure.
export const analysisSchema = z.object({
topic: z.string(),
keyPoints: z.array(
z.object({
point: z.string(),
sourceIds: z.array(z.string()),
importance: z.enum(["high", "medium", "low"]),
}),
),
outline: z.array(
z.object({
heading: z.string(),
description: z.string(),
sourceIds: z.array(z.string()),
}),
),
risks: z.array(
z.object({
description: z.string(),
sourceIds: z.array(z.string()),
}),
),
missingEvidence: z.array(z.string()),
needsMoreResearch: z.boolean(),
summary: z.string(),
});
Unlike the general article outline, every key argument and chapter here can be linked to the source ID. This gives Writer the opportunity to judge whether a particular conclusion has a research basis, rather than simply generating content based on the knowledge already available in the model.
2.3 DraftResult
Writer Agent is responsible for converting research findings and articles to Markdown.
export const draftSchema = z.object({
title: z.string(),
description: z.string(),
tags: z.array(z.string()),
categories: z.array(z.string()),
content: z.string(),
usedSourceIds: z.array(z.string()),
notes: z.array(z.string()),
});
Contact saves Markdown text. UsedsourceIDs to save the actual quoted source. Notes for recording editorial notes requiring the attention of Reviewer, such as evidentiary limitations, lack of performance data, etc.
There is an important principle here: Writer can organize and interpret evidence that is available, but should not fabricate evidence to make it more complete.
2.4 ReviewResult
Reviewer's output determines the next step execution path for Workflow.
import { z } from "zod";
export const reviewIssueSchema = z.object({
severity: z.enum(["high", "medium", "low"]),
description: z.string(),
suggestedFix: z.string(),
});
export const reviewSchema = z.object({
decision: z.enum(["approve", "revise", "research"]),
score: z.number().min(0).max(100).nullable(),
issues: z.array(reviewIssueSchema),
feedback: z.string(),
});
field, the most important here, is decision: approve, which states that the article has been reviewed; revise, which states that it can be modified with available information; and research, which states that it lacks the necessary evidence and needs additional research.
In order to avoid unreasonable audit results, I also use superrefine() to add operational rules:
export const validatedReviewSchema = reviewSchema.superRefine(
(review, ctx) => {
if (review.decision !== "approve" && review.issues.length === 0) {
ctx.addIssue({
code: "custom",
path: ["issues"],
message: "revise/research decisions must include actionable issues",
});
}
if (
review.decision === "approve" &&
review.issues.some((issue) => issue.severity === "high")
) {
ctx.addIssue({
code: "custom",
path: ["decision"],
message: "Cannot approve a draft with high-severity issues",
});
}
},
);
It needs to be noted that LLM is passed on to basic reviewSchema, while operational verification is done using validatedreviewSchema. This would separate the JSON structure from the operational rules.
Research Agent: Making the knowledge base a source of research
Research Agent uses the knowledge retrieval tool from previous RAG projects.
import { Agent } from "@mastra/core/agent";
import { knowledgeSearchTool } from "../tools/knowledge-search.tool.js";
import { workflowModel } from "../lib/openai.js";
export const researchAgent = new Agent({
id: "research-agent",
name: "Research Agent",
model: workflowModel,
tools: { knowledgeSearchTool },
instructions: `
You are a technical research specialist.
Responsibilities:
1. Search the knowledge base for relevant information.
2. Extract factual findings.
3. Record source references.
4. Identify missing evidence.
Rules:
- Always search before answering.
- Never invent sources.
- Clearly distinguish facts and assumptions.
- If evidence is insufficient, say so.
- Do not write the final article.
Respond in Chinese.
`,
});
Retrieval of tool call-realized searchKnowledge():
const results = await searchKnowledge(query, { topK: 5 });
The bottom is still the previously achieved PostgreSQL/pgvector retrieval programme.
The first design problem I've encountered here is that Research Agent needs not only to call tools but also to return structured data.
Initially I tried to get it done directly, but I had problems with model compatibility and output stability. Finally, an easier debugging approach has been adopted: separate research from formatting.
export async function conductResearch(prompt: string) {
const report = await researchAgent.generate(prompt, { maxSteps: 8 });
if (!report.text.trim()) {
throw new Error("Research returned an empty report");
}
const formatted = await researchFormatterAgent.generate(
`Convert the research report into JSON.\n\nResearch report:\n${report.text}`,
{ structuredOutput: { schema: researchSchema } },
);
return researchSchema.parse(formatted.object);
}
The complete format requirements and source consistency verification in the actual project were omitted.
This design does not mean that a stand-alone Agent must be created for each formatting task. It's just one of the ways I chose in this POC. If costs need to be reduced in the future, direct use of a general structured model could also be considered.
IV. Organizing multiple Agents using Mastra Workflow
It's not complicated to call four Agents alone. What's really complicated is: how do we get the Reviewer output to determine the follow-up process and get Writer to use the last round of audits to continue changing articles?
I use Mastra's CreateWorkwork() and CreateStep() to achieve this process.
4.1 Initialization phase
The initialization phase is carried out in a sequential manner:
const initializeStep = createStep({
id: "initialize-article",
inputSchema,
outputSchema: stateSchema,
execute: async ({ inputData }) => {
const { topic } = inputData;
console.log("[Research] Starting...");
const research = await conductResearch(`Research this topic: ${topic}`);
console.log("[Analysis] Starting...");
const analysisResponse = await analysisAgent.generate(
`Analyze this research:\n${JSON.stringify(research)}`,
{ structuredOutput: { schema: analysisSchema } },
);
const analysis = analysisSchema.parse(analysisResponse.object);
console.log("[Writer] Creating initial draft...");
const writerResponse = await writerAgent.generate(
`Write an article using:\n\nResearch:\n${JSON.stringify(research)}\n\nAnalysis:\n${JSON.stringify(analysis)}`,
{ structuredOutput: { schema: draftSchema } },
);
const draft = draftSchema.parse(writerResponse.object);
validateCitations(research, draft);
return {
topic,
research,
analysis,
draft,
reviewHistory: [],
reviewCount: 0,
researchRounds: 0,
maxReviews: inputData.maxReviews,
maxResearchRounds: inputData.maxResearchRounds,
status: "pending" as const,
};
},
});
This code shows the core structure, and complete realization also contains stricter Prompt and error processing.
The state Schema here is very important. It preserves research results, analytical results, current drafts, audit history and circular counters. The next review will need to read the latest draft, not the first-time articles.
4.2 Reviewer Decision-making
Reviewer:
const response = await reviewerAgent.generate(prompt, {
structuredOutput: { schema: reviewSchema },
});
const review = validatedReviewSchema.parse(response.object);
The next step operation is then decided on the basis of the audit.
if (review.decision === "approve") {
return { ...base, status: "approved" as const };
}
if (nextCount >= maxReviews) {
return { ...base, status: "max_reviews_reached" as const };
}
if (review.decision === "research") {
/ Supplementary research, re-analysing and rewrite articles
}
//revise: modify articles with available evidence
Writer:
const writerResponse = await writerAgent.generate(
`Revise this Chinese engineering article.
Current DraftResult:
${JSON.stringify(draft)}
Reviewer:
${JSON.stringify(review)}
Address each issue using existing evidence.
Do not invent new source IDs or facts.
Return a complete DraftResult.`,
{ structuredOutput: { schema: draftSchema } },
);
This is different from a simple “regeneration article”. Writer is able to read the last round of articles and propose specific changes and continues to work around the existing content.
4.3 Perform audit cycle using dountil
Last use of.dountil() to connect audit steps:
export const fullArticleWorkflow = createWorkflow({
id: "full-article-workflow",
inputSchema,
outputSchema: stateSchema,
})
.then(initializeStep)
.dountil(
reviewLoopStep,
async ({ inputData }) => inputData.status !== "pending",
)
.then(finalizeStep)
.commit();
As long as the state remains pending, the process continues. If the Reviewer returns approve, or reaches the maximum number of audits, exits the cycle.
I also set up maxResearchRunds to avoid the system of unlimited additional information.
It is important to emphasize that although the research branch has been achieved, it has not been triggered by the successful tests presented here, and therefore the supplementary study path cannot be considered fully validated by this operation alone.
Problems encountered in practical development
This part of the project is the most time-consuming place.
At first, I thought the most difficult was the workflow of Agent. Physical development only reveals that the stability of structured output is more of a problem.
5.1 DeepSeek returns legally to JSON, but not to Schema
Initially, DeepSeek was used to access Mastra through Open AI Compatible API.
import { createOpenAICompatible } from "@ai-sdk/openai-compatible";
export const deepseek = createOpenAICompatible({
name: "deepseek",
baseURL: "https://api.deepseek.com/v1",
apiKey: process.env.DEEPSEEK_API_KEY!,
});
The first was the request restriction in the JSON mode:
Prompt must contain the word 'json' in some form
to use 'response_format' of type 'json_object'.
The problem is relatively easy to solve, only to explicitly request return to JSON in Prompt. But the problems that have arisen have become more problematic.
Analysis Agent, for example, should return:
{
"keyPoints": [
{
"point": "MD5 is not suitable for password storage",
"sourceIds": ["S1"],
"importance": "high"
}
]
}
It is possible to return:
{
"keyPoints":
}
Both are legal in terms of JSON grammar. From Zod Schema, however, it appears that the second does not meet the requirements.
In addition, I have met:
- isks returns a string array instead of an object array.
- Missing Evidence was replaced with evidence Gaps.
- You're gonna have to fill in the lack of Summary field.
- The field type and embedded object structure are not consistent.
The result is:
STRUCTURED_OUTPUT_SCHEMA_VALIDATION_FAILED
These problems may be just one retry in a single Agent. But in multiple Agent WorkFlow, an output that does not fit Schema at some stage affects the entire implementation chain.
In order to accommodate these outputs, I tried to convert the data returned from the model into the expected structure, using a large amount of z.preprocess().
const risksSchema = z.preprocess(
(value) => {
if (!Array.isArray(value)) return value;
return value.map((item) => {
if (typeof item === "string") {
return { description: item, sourceIds: [] };
}
return item;
});
},
z.array(riskSchema),
);
This approach would address some of the variations in format, but would also create additional complexity.
I have come to realize that if a large number of codes are needed to modify the LLM output format, the model may not match the task.
5.2 Switch GPT-4.1 Mini
To improve the stability of structured output, I eventually switched four operations to GPT-4.1 Mini.
Custom Open AI compatible API addresses used:
import type { MastraModelConfig } from "@mastra/core/llm";
export const workflowModel: MastraModelConfig = {
id: "bianxie/gpt-4.1-mini",
url: "https://api.bianxie.ai/v1",
apiKey: process.env.OPENAI_API_KEY!,
api: "chat",
};
This is a third-party Open AI compatible agent, not a direct connection to Open AI official API. Actual deployment requires self-assessment of the reliability, privacy and compatibility of agency services.
The switch also encountered an AI SDK compatibility problem with Mastra:
Type 'LanguageModelV4' is not assignable to
type 'MastraLanguageModelV4'.
This is due to the type difference between the directly created AI SDK model object and the current model interface accepted by Mastra. End-use the Mastra-supported model configuration object to avoid direct entry of incompatible model examples.
5.3 Open AI Strike JSON Schema does not accept optional field
After switching the model, the first three Agents are ready for normal execution. But Reviewer has another problem:
Invalid schema for response_format 'response':
'required' is required to be supplied
and to be an array including every key
in properties. Missing 'score'.
Then Schema was:
score: z.number().min(0).max(100).optional(),
Normal Zod verification allows field to exist. However, all properties of the objects requested by Open AI Stractued Outputs appear in required.
The solution is to replace the option() with a nullaable():
score: z.number().min(0).max(100).nullable(),
This score still has to return, but allows its value to be nulla.
This question makes me more clear: Zod Schema can pass the Type Type check by TypeScript, which does not mean that it can be used directly as LLM 's Strict JSON Schema.
Therefore, I will structify the output of Schema from the validation of the rules of operation, avoiding all constraints being inserted into the model output phase.
5.4 Quoting verification also leads to the failure of Workflow
Another problem arises after Writer generated the article. The mistake was:
Declared but unused source: S1
Writer declared that S1 was used, but the verification program did not identify the corresponding references in the body. Initial realization only supports [S1] this format. However, models may generate different forms [S1, S2], (S1) etc.
So I adjusted the logic of the quotes:
const citationGroups = [
...draft.content.matchAll(
/[\[((]((?:S\d+)(?:\s*[,,、;;]\s*S\d+)*)[\]))]/g,
),
];
const cited = new Set(
citationGroups.flatMap((match) => match[1].match(/S\d+/g) ?? []),
);
Next check: Whether the statement originates from ResearchResult; Whether sources are quoted in the text; Whether the text references are included in the usedsourceIds.
For the stated unused source, the POC phase only output warnings, not directly terminates the WorkFlow.
It needs to be noted that such verification can only guarantee the basic consistency of the reference to ID. It cannot prove that a particular technical finding is actually supported by a counterpart source, much less that the source itself is reliable. This still requires more in-depth proof.
VI. Final operational results
After completing the above adjustments, I run the wholework:
npx tsx src/scripts/test-full-article-workflow.ts \
"Why should the Go backend move from MD5 to Argon2id?"
This time it's a success.
[Research] Starting...
[Analysis] Starting...
[Writer] Creating initial draft...
[Citation] Validation passed
{ declared: [ 'S1' ], cited: [ 'S1' ] }
[Review 1/3] revise
[Writer] Revising from feedback...
[Citation] Validation passed
{ declared: [ 'S1' ], cited: [ 'S1' ] }
[
Auto-fetched and translated by AI Atlas. All rights belong to the original author.