用十七个小型MCP工具替代一个大型智能体:一种人工智能写作系统
一种研究与写作系统如何将视频获取、搜索、草稿撰写、评审以及代码验证等功能拆分为由边界明确的MCP工具来完成,并实现相关成果的记录与追踪。
来源: Why I Built MaxyAI as 17 Small MCP Tools Instead of One Giant Agent
AI写作助手的难点不在于让语言模型进行写作,而在于确定模型不应承担哪些任务。早期的此类系统大多采用同一架构:将某个主题交给单一模型,再为其配备搜索工具,要求其在一次连续的操作中完成研究、推理、写作、验证以及错误修复。随着提示词变长,流程调试变得困难,确定性故障也会与语义决策相互交织。AI研究与写作系统MaxyAI则基于另一种思路:让所有具有不同输入、故障模式、重试规则或可复用输出的功能,都在单个MCP服务器背后成为独立的受限工具。
输入内容往往并不整齐有序,可能是一段YouTube讲座、一个GitHub仓库、一个文档页面以及几条参考链接。MaxyAI会将这些全部接收进来,通过网络搜索扩展研究范围,将不断扩充的资料存储在磁盘上并整理成大纲。随后它逐节撰写文章,将初稿交给数值评估工具,提取出所有的Python代码块,对代码进行验证和运行,修复语法错误,最终输出一篇独立的HTML文章。
背后并没有什么复杂的提示词,该系统实际上是一个FastMCP服务器,配备了17个已注册的工具,其中12个用于研究用途。这些工具都被设计得较为简单:有一个用于视频转录,一个用于理解仓库结构,一个用于网络搜索,一个用于生成大纲,一个用于文章评估,还有一个用于代码验证。模型负责决定调用哪种功能,而每个工具背后的Python代码则负责执行具体的任务。
这种分离正是该设计最实用之处。模型可以更改而无需影响 YouTube 数据的导入流程;爬虫可以更换而不干扰内容生成模块;代码验证阶段可以调试而不必重新运行研究任务。由于每个重要步骤都会留下相应的产物或痕迹,因此可以事后检查长时间的自动运行过程,而无需将其视为一大段连续的聊天记录。
数字17并非预设目标,它源自一条简单规则:当某个功能需要与不同的外部系统交互、生成可重复使用的产物、需要独立的重试机制,或能从确定性验证中获益时,它就会拥有专属的有限功能工具或处理程序。MCP则为所有这些专用工具提供了统一的接口。
为何不使用一个大型工具?因为该系统要解决的是多个独立的工程问题,而非单一的推理问题。这些任务(视频导入、代码库解析、网页发现、状态管理、草稿撰写、评审、代码运行、错误修复、内容发布)各自都有不同的输入、成本、故障模式以及重试需求。使用一个超级工具虽然能简化指令,但会降低系统的可观察性并增加扩展难度。
这并非意味着每个智能体都需要17个工具。这其实是一个关于任务分解的案例研究:语义判断应由模型负责,普通代码应继续承担核心功能,如何用中间产物替代庞大的对话状态,以及为何生成的代码在呈现给用户之前必须通过确定性检查。
从开始到结束的完整流程
以大语言模型函数调用与结构化输出为主题,初始提供一些指导资料,比如一个YouTube视频、一个GitHub仓库以及几个文档链接。
第一阶段不会生成任何文字内容,其任务是收集证据。MCP服务器会筛选出相关URL,将视频传递给Gemini的多模态API,通过gitingest将仓库内容转换为模型易于处理的摘要,用Firecrawl爬取页面,通过Tavily填补信息空白,并在.nova/目录下的状态文件中记录所有资料来源。最终生成的将是research.md,这是一个可供任何人查看和阅读的完整研究文档,取代了以往隐藏的对话记录。
第二阶段会基于该初稿规划文章的发布方案。generate_article_outline会分配各章节内容、字数要求及代码相关指标,随后draft_article_section会每次调用生成一个完整的章节内容。接着,critic_evaluate_article会从技术深度、代码质量、结构合理性以及引用准确性等方面对完整初稿进行评估,最后refine_and_save_article会根据评估结果进行修改,再生成article.md文件。
第三阶段要求Markdown代码在执行后仍能正常运行,而不仅仅是看起来合理。Python代码块会被提取出来并使用ast.parse()进行解析。有效的代码片段会被保存在.nova/sandbox/目录下,并以五秒为超时时间进行运行测试。如果出现语法错误,错误信息及相关代码片段会被发送到fix_code_snippets函数,之后再重新进行验证。一旦代码通过验证,export_publication_bundle函数就会生成独立的HTML文档。
Opik追踪贯穿整个运行过程。每个模型和工具的追踪信息都会显示延迟时间、提供方元数据、工具层级结构以及中间输出结果。从架构图来看,整个系统分为三个阶段(研究、编写与评审、代码质量检测与发布),这些阶段共享模型故障转移机制与可观测性功能。
MCP服务器作为核心支撑
第一个设计决策是在协议层面:通过 Model Context Protocol 暴露功能,而非将每个功能硬编码到单个应用程序循环中。
MCP 为客户端提供了用于发现和调用工具的标准术语。FastMCP 让在 Python 中实现这一功能变得十分便捷,因为普通的带类型注解的函数只需通过装饰器即可变成 MCP 工具,其函数签名也会成为该工具契约的一部分。即便客户端是代理程序,实现部分仍可使用纯 Python 编写。
在该项目中,mcp_server/src/routers/tools.py 是工具层的入口点。简化的注册方式如下:
import opik
from fastmcp import FastMCP
from ..tools import run_web_research_tool
def register_mcp_tools(mcp: FastMCP) -> None:
@mcp.tool(name="run_web_research")
@opik.track(type="tool", tags=["research", "tavily"])
async def run_web_research(
queries: list[str],
research_directory: str,
) -> dict:
return await run_web_research_tool(
queries=queries,
research_directory=research_directory,
)
装饰器的作用不仅仅是美化语法。客户端看到的是带有类型参数的命名工具,而实际实现则留在工具层和处理层中。一旦服务器的功能超出有限范围,这种分离就显得非常重要。
刻意避免了使用单一的do_everything(topic)工具,因为这会重新引入设计初衷要消除的耦合关系。较小的工具能让模型组合不同的行为:提取来源、检查缺失内容、再次搜索、列出大纲、起草内容、进行评审、修复问题。此外,由于每个操作阶段都对应一个有意义的动作,这样的设计也使得追踪记录更易于理解。
因此,拥有17种工具的服务器并非17个独立的提示功能,而是一组有限的功能模块。其中研究相关功能占比最大,共有12种工具,因为获取资料是整个工作流程中最需要多样化处理方式的环节。
研究:不同的来源,不同的工具
研究工作占据了最多的设计精力,因为“搜索网络”这一操作实际上隐藏了多项处理步骤。视频、GitHub 仓库以及依赖大量 JavaScript 的文档网站会带来三种不同的数据处理问题,若使用同一套爬虫工具来处理这些内容,从一开始就会导致内容背景信息不完整。
用于 YouTube 的 Gemini
在本地处理长视频成本很高:需要下载音频、进行语音转文字处理、管理 GPU 内存,最后还要对转录结果进行总结。youtube_handler.py 模块则采用了更简单的方法——它直接将 YouTube 的公开网址作为多模态输入传递给 Gemini,并要求其输出带有时间戳的结构化 Markdown 文本。
选择 Gemini 并非因为它是最适合整个项目的模型,而是因为它的功能恰好符合需求:原生多模态接入功能意味着服务器无需本地 Whisper 库或客户端机器上的 GPU 即可处理视频。
提供方服务器出现的错误由同一模块通过指数退避机制处理,将模型端的临时故障限制在工具内部,这样外部代理无需了解网络相关细节。
@retry(
retry=retry_if_exception_type(errors.ServerError),
wait=wait_exponential(multiplier=1, min=5, max=60),
stop=stop_after_attempt(3),
)
async def transcribe_youtube(url: str, output_path: Path, timestamp: int = 30):
client = track_genai_client(
genai.Client(api_key=settings.google_api_key.get_secret_value())
)
parts = [
types.Part(file_data=types.FileData(file_uri=url)),
types.Part(text=PROMPT_YOUTUBE_TRANSCRIPTION.format(timestamp=timestamp)),
]
response = await client.aio.models.generate_content(
model=settings.youtube_transcription_model,
contents=parts,
)
output_path.write_text(response.text, encoding="utf-8")
在某次测试中,时长39分钟的讲座在17.8秒内就被转换成了结构化笔记,且未使用任何客户端GPU内存。这只是实际测试结果,并非通用基准,因为服务提供商的负载和网络状况都可能影响结果。记录的追踪信息显示了模型标识符gemini-3.7-flash以及被保存的.nova/guidelines_filenames.json状态文件。
仓库的gitingest处理
仓库并非网页。模型需要的是文件结构及实际代码内容,而非GitHub的导航界面,因此GitHub的处理程序会使用gitingest来生成适合大语言模型处理的仓库摘要。
AST验证是另一回事。gitingest仅负责数据导入,为研究模型提供目录映射和文件内容,而Python的AST解析器则要在代码发布流程检查生成的代码时才会被使用。将两者混用会使架构变得复杂。
将它们分开也有利于扩展。后续的GitLab或本地文件夹导入工具只需输出相同的研究成果,因为代码的来源对生成工具来说并无区别。
Tavily与Firecrawl的结合
这两者解决的是网页研究中的不同环节。Tavily有助于发现目标,将“我还缺少什么?”这样的疑问转化为一系列有针对性的查询。而当服务器选定特定页面并需要完整提取其内容时,Firecrawl就能发挥作用。
该项目利用这一差异使研究过程具有迭代性。导入初始参考资料并非终点,该系统会遍历整个语料库,针对仍缺失的内容编写新的查询,执行这些查询,将返回的结果添加进来,这一循环可能会重复最多三轮。
其设计刻意采用非对称结构:大语言模型负责判断哪些内容缺失,而 Tavily 和 Firecrawl 负责信息检索。这种模式贯穿始终——在需要语义判断的场景中使用模型,在适合确定性处理的场景中使用普通代码和服务。从某次研究过程中可以看出,Firecrawl 在开始撰写内容之前会先爬取各种不同的技术资源。
使用 .nova/ 目录中的文件,而非单一的庞大对话记录
研究过程很容易产生数万个标记,若将每一页爬取的内容及中间决策结果都传递给后续的每个工具调用,将会造成资源浪费。
相反,系统会在.nova/目录下生成一些小型控制文件,如guidelines_filenames.json和next_queries.json,而将大型内容保存在research.md之类的文件中。模型仅接收当前操作所需的那一部分内容。
这种方式虽不及“智能体记忆”那么神奇,但更容易查看。当运行中途停止时,状态目录会显示已收集的参考资料或下一轮搜索该何时进行。实际上,文件系统起到了在各阶段之间建立一种简单却可行的协作机制的作用。
生成器即处理流程
一旦有了 research.md,人们很自然地会想将整个文件输入大型模型,要求生成一篇2000字的文章。但该项目从一开始就避免了这种做法,因为输出限制和长上下文注意力机制使得很难同时控制文章结构、代码覆盖范围以及引用内容。
写作阶段则将任务分配给四种工具来完成:
generate_article_outline会将研究资料转换为包含字数限制和代码目标的分节列表。draft_article_section会逐个生成有限长度的章节,而非一次性输出完整文章。critic_evaluate_article会依据结构化的0到100分评分标准对已完成的草稿进行评估。
refine_and_save_article会进行必要的修改,生成目录,检查引用结构,然后保存article.md文件。重要的是,评审者不能只说“看起来不错”。它应输出结构化的反馈,以便其他工具能够据此进行处理:
{
"overall_score": 85,
"status": "APPROVED",
"category_scores": {
"technical_depth": 27,
"code_quality": 26,
"structure_clarity": 17,
"citation_fidelity": 15
},
"specific_critiques": [
"Section 3.2 mentions timeout handling but omits the asyncio.wait_for example.",
"Citation [14] is cited twice with conflicting author names."
],
"required_revisions": [
"Add the timeout example.",
"Deduplicate bibliography reference [14]."
]
}
这种模式会逐步缩小模型所承担的任务范围。概要模型负责确定结构,起草者负责整合某一部分内容,审核者负责查找缺陷,而优化步骤则依据检查清单进行操作。不会要求单个模型在一次性完成中承担所有四项工作。
100分中的85分并非科学的质量衡量标准,而是一种工作流程控制信号,其价值在于机器可读取的审核意见与修改清单。
真实的写作流程痕迹会体现为结构化的提示、对话模型的调用以及Pydantic解析器,这类记录能让自主写作流程在事后得以理解。
代码作为可执行成果
即便技术文章写得很好,但如果其中的代码无法运行,仍会让读者失望。这就是为何需要第三阶段的原因。
构建工具会打开article.md文件,提取其中的Python代码块,使用ast.parse()对每个代码块进行验证,将经过验证的代码片段保存到.nova/sandbox/目录中,以子进程的形式运行这些代码块并捕获标准输出和标准错误信息,对于出错的代码块则会让其再次通过大型语言模型修复工具进行处理。
静态解析与执行能够检测不同类型的问题。ast.parse()处理速度较快,能够立即识别无效的Python代码;而子进程则可以捕获仅靠语法分析无法发现的运行时错误。
def validate_python_syntax(code: str):
try:
ast.parse(code)
return True, None
except SyntaxError as exc:
return False, f"SyntaxError at line {exc.lineno}: {exc.msg}"
result = subprocess.run(
[sys.executable, str(file_path)],
capture_output=True,
text=True,
timeout=5,
env={"PYTHONUNBUFFERED": "1"},
)
在文章编译之前,有问题的代码片段会先被修复,然后再次进行验证。曾有一例,生成的文章在 Python 代码块中包含了 pip install google-genai 这行代码。在 Markdown 格式中它看起来似乎没问题,但 Python 却立即拒绝了它。fix_code_snippets 功能接收到错误追踪信息及出问题的代码块后,重新编写了该代码片段并再次提交进行验证。
# Broken
pip install google-genai
from google import genai
# Repaired
# Requires: pip install google-genai
import os
from google import genai
client = genai.Client(
api_key=os.environ.get("GEMINI_API_KEY", "mock-key")
)
修复循环被刻意设计为在本地进行。由于某个代码片段出错,模型不允许重写整篇文章。它会获取最小的可用单元,即代码及其错误,而确定性检查则作为最终审核关卡。
简单来说:article.md中的Python代码会被扫描,每个代码片段都会被解析并存储在.nova/sandbox/目录下,然后在受限的子进程中执行。一旦出现错误,代码及错误堆栈信息就会被打包传递给fix_code_snippets函数,随后提出的修复方案会再次经过同样的验证流程。模型永远不会自行批准自己的修复方案——由大语言模型提出建议,再通过ast.parse()解析及实际执行来做出决定。只有当循环结束后,export_publication_bundle函数才会将经过审核的内容转换为独立的HTML格式输出。
有一个边界需要特别强调:执行层是一个受保护的子进程运行器,而非强化型的安全沙箱。超时机制与子进程能够有效检测语法错误、运行时故障及程序挂起问题,从而保障文章质量。对于真正不可信的代码,则需要更强的隔离措施,比如容器、命名空间、seccomp机制、微虚拟机,或是带有文件系统、网络、系统调用、CPU及内存限制的专用沙箱服务。
两个共享层:模型路由与追踪
虽然可见的架构由三个阶段构成,但在日常使用中,两个共享组件同样至关重要。
考虑配额限制的模型路由
一次研究任务可能会发起大量模型调用,很容易耗尽免费套餐的调用配额。为避免在每个处理函数中都进行针对不同提供商的初始化操作,模型创建功能被集中到llm_utils.py文件中,而提供商信息及API密钥配置则保存在设置文件中。
该项目是在 Gemini Flash、Gemini Flash Lite 以及由 Groq 托管的 Llama 等系列模型基础上发展起来的。这些特定模型并非永久存在,但这并非重点。所有工具都与模型抽象层进行交互,研究和开发代码中并未嵌入任何提供方的相关细节。
实验成本也会降低,因为将任务转移到成本更低或当前可用的模型上时,不会影响 MCP 工具的接口规范。
def get_chat_model(model_id: str, schema=None):
selected = settings.llm_configs[model_id]
identifier = selected["identifier"]
params = selected.get("params", {}).copy()
# resolve provider-specific API key
...
model = init_chat_model(identifier, **params)
return track_langchain_model(model)
Opik让长流程变得可追踪
一旦MCP服务器调用某个模型,该模型又调用工具生成结果,进而触发另一个模型,日志就失去了作用。通过Opik追踪功能对模型客户端和MCP工具进行监控,可以直接展示这一层级结构,无需通过打印语句来拼凑信息。
其实用价值很高:可以对比缓慢的Firecrawl执行步骤与模型调用过程,确定实际响应的模型,查看解析器接收到的数据内容,还能重现生成错误结果的路径。这些追踪截图是理解工作流程的开发工具,而非装饰性内容。
该项目对智能体设计的启示
能够生成文章并非MaxyAI的独特之处,因为现有的前沿模型已经能够将提示转化为不错的内容。真正值得称道的是其工作流程的分解方式:视频导入、代码库解析、搜索、大纲制定、初稿撰写、内容评审、代码提取、验证、修复以及导出,这些都是独立的工具。MCP为这些工具提供了统一的接口,磁盘上的中间结果使得各阶段可以传递大量状态而无需将其存储在聊天内存中,而Opik则让整个处理流程一目了然。
扩展点很容易识别。PDF导入工具为第一阶段提供了另一种数据来源。更换Tavily模型不会影响内容撰写功能。可以在第三阶段添加JavaScript验证功能,同时研究引擎保持不变。更换模型提供商也不会改变MCP的接口结构。
为实际应用做强化工作首先需要更强的沙箱隔离机制,接着是涵盖研究、引用及代码质量方面的固定评估套件,更清晰的提供方能力元数据以及可靠的运行检查点。即便在目前,该项目也指出:模型可以决定下一步行动,但系统的质量取决于围绕它的工具、边界、输出结果以及反馈循环。
有三点工程经验可应用于其他智能体系统:
- 工具的边界比数量更重要。如果某项能力需要接入其他系统、出现故障或以不同方式重试,或者会产生会被他人重复使用的输出结果,那就应为它设定独立的边界。
- 尽可能让模型提出建议,再由确定性检查来做出决策。大型语言模型可以修正代码片段,但最终的判断仍需通过解析和执行来完成。